From 9350af42e299f9da85ea2d046792a76e3a562cba Mon Sep 17 00:00:00 2001 From: albert Date: Sun, 13 Sep 2026 18:44:27 -0700 Subject: [PATCH 01/25] Bump version to v0.2.2 --- linux/io.busystack.busymax.metainfo.xml | 5 +++++ pubspec.yaml | 2 +- snap/snapcraft.yaml | 2 +- 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/linux/io.busystack.busymax.metainfo.xml b/linux/io.busystack.busymax.metainfo.xml index af8bb41b..8dff3ff0 100644 --- a/linux/io.busystack.busymax.metainfo.xml +++ b/linux/io.busystack.busymax.metainfo.xml @@ -100,6 +100,11 @@ + + +

Beta maintenance release.

+
+

Beta maintenance release.

diff --git a/pubspec.yaml b/pubspec.yaml index 9752b2cf..b20926a1 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: busymax description: BusyMax calendar and task manager. publish_to: 'none' -version: 0.2.1 +version: 0.2.2 environment: sdk: ^3.12.0 diff --git a/snap/snapcraft.yaml b/snap/snapcraft.yaml index 7d800cc7..b9faf36c 100644 --- a/snap/snapcraft.yaml +++ b/snap/snapcraft.yaml @@ -1,6 +1,6 @@ name: busymax title: BusyMax -version: "0.2.1" +version: "0.2.2" summary: Calendar and task manager description: | BusyMax is a Linux desktop calendar and task manager. From 263a99e5b747fa344de3c8c775ccda2d4bbdfbe0 Mon Sep 17 00:00:00 2001 From: albert Date: Mon, 14 Sep 2026 13:58:39 -0700 Subject: [PATCH 02/25] Add Android support --- .github/workflows/flutter-android.yml | 61 + .metadata | 5 +- .run/BusyMax Android.run.xml | 7 + NOTICE | 9 + README.md | 40 +- analysis_options.yaml | 3 + android/.gitignore | 16 + android/app/build.gradle.kts | 163 + android/app/proguard-rules.pro | 18 + android/app/src/debug/AndroidManifest.xml | 7 + android/app/src/main/AndroidManifest.xml | 100 + .../io/busystack/busymax/MainActivity.kt | 5 + .../res/drawable-v21/launch_background.xml | 12 + .../res/drawable/ic_launcher_monochrome.xml | 8 + .../src/main/res/drawable/ic_notification.xml | 10 + .../main/res/drawable/launch_background.xml | 12 + .../res/mipmap-anydpi-v26/ic_launcher.xml | 6 + .../mipmap-anydpi-v26/ic_launcher_round.xml | 6 + .../src/main/res/mipmap-hdpi/ic_launcher.png | Bin 0 -> 6938 bytes .../mipmap-hdpi/ic_launcher_foreground.png | Bin 0 -> 3943 bytes .../res/mipmap-hdpi/ic_launcher_round.png | Bin 0 -> 6938 bytes .../src/main/res/mipmap-mdpi/ic_launcher.png | Bin 0 -> 3668 bytes .../mipmap-mdpi/ic_launcher_foreground.png | Bin 0 -> 2250 bytes .../res/mipmap-mdpi/ic_launcher_round.png | Bin 0 -> 3668 bytes .../src/main/res/mipmap-xhdpi/ic_launcher.png | Bin 0 -> 10674 bytes .../mipmap-xhdpi/ic_launcher_foreground.png | Bin 0 -> 6213 bytes .../res/mipmap-xhdpi/ic_launcher_round.png | Bin 0 -> 10674 bytes .../main/res/mipmap-xxhdpi/ic_launcher.png | Bin 0 -> 21095 bytes .../mipmap-xxhdpi/ic_launcher_foreground.png | Bin 0 -> 11624 bytes .../res/mipmap-xxhdpi/ic_launcher_round.png | Bin 0 -> 21095 bytes .../main/res/mipmap-xxxhdpi/ic_launcher.png | Bin 0 -> 32321 bytes .../mipmap-xxxhdpi/ic_launcher_foreground.png | Bin 0 -> 17914 bytes .../res/mipmap-xxxhdpi/ic_launcher_round.png | Bin 0 -> 32321 bytes .../app/src/main/res/values-night/styles.xml | 18 + android/app/src/main/res/values/colors.xml | 4 + android/app/src/main/res/values/styles.xml | 18 + android/app/src/main/res/xml/backup_rules.xml | 6 + .../main/res/xml/data_extraction_rules.xml | 11 + android/app/src/profile/AndroidManifest.xml | 7 + android/build.gradle.kts | 27 + android/busymax.android.properties.example | 20 + android/gradle.properties | 7 + android/gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 53636 bytes .../gradle/wrapper/gradle-wrapper.properties | 5 + android/gradlew | 160 + android/gradlew.bat | 90 + android/key.properties.example | 5 + android/settings.gradle.kts | 28 + docs/android_implementation_status.md | 40 + docs/android_provider_parity.md | 20 + docs/android_release.md | 62 + docs/android_setup.md | 99 + docs/android_verification.md | 162 + docs/privacy_data_map.md | 36 +- .../android_platform_smoke_test.dart | 16 + lib/main_android.dart | 26 + lib/src/android/android_account_gate.dart | 19 + lib/src/android/android_app.dart | 292 + lib/src/android/android_authorization.dart | 281 + lib/src/android/android_background.dart | 178 + lib/src/android/android_notifications.dart | 785 ++ lib/src/android/android_time_zone.dart | 15 + .../presentation/android_schedule_screen.dart | 1395 +++ .../presentation/android_settings_screen.dart | 1597 +++ .../presentation/android_tasks_screen.dart | 819 ++ lib/src/app/app_bootstrap.dart | 122 +- lib/src/app/app_settings.dart | 21 +- lib/src/config/build_config.dart | 31 +- lib/src/core/auth/account_token_broker.dart | 49 + lib/src/db/app_database.dart | 2 + lib/src/db/app_database.g.dart | 9788 ++++++++++------- lib/src/db/migrations.dart | 8 +- lib/src/db/tables.dart | 33 + .../features/auth/data/auth_repository.dart | 6 +- .../external_location_launcher.dart | 16 +- .../notification_reconciler.dart | 34 + .../sync/account_sync_operations.dart | 37 + .../presentation/task_details_draft.dart | 3 +- .../oauth/microsoft_oauth_service.dart | 15 +- packages/busymax_android_platform/.gitignore | 33 + packages/busymax_android_platform/.metadata | 30 + .../busymax_android_platform/CHANGELOG.md | 6 + packages/busymax_android_platform/LICENSE | 13 + packages/busymax_android_platform/README.md | 24 + .../analysis_options.yaml | 14 + .../android/.gitignore | 9 + .../android/build.gradle.kts | 83 + .../android/settings.gradle.kts | 1 + .../android/src/main/AndroidManifest.xml | 17 + .../BusymaxAndroidPlatformPlugin.kt | 597 + .../BusymaxAndroidPlatformPluginTest.kt | 17 + .../example/.gitignore | 48 + .../example/README.md | 17 + .../example/analysis_options.yaml | 33 + .../example/android/.gitignore | 14 + .../example/android/app/build.gradle.kts | 47 + .../android/app/src/debug/AndroidManifest.xml | 7 + .../android/app/src/main/AndroidManifest.xml | 45 + .../MainActivity.kt | 5 + .../res/drawable-v21/launch_background.xml | 12 + .../main/res/drawable/launch_background.xml | 12 + .../src/main/res/mipmap-hdpi/ic_launcher.png | Bin 0 -> 544 bytes .../src/main/res/mipmap-mdpi/ic_launcher.png | Bin 0 -> 442 bytes .../src/main/res/mipmap-xhdpi/ic_launcher.png | Bin 0 -> 721 bytes .../main/res/mipmap-xxhdpi/ic_launcher.png | Bin 0 -> 1031 bytes .../main/res/mipmap-xxxhdpi/ic_launcher.png | Bin 0 -> 1443 bytes .../app/src/main/res/values-night/styles.xml | 18 + .../app/src/main/res/values/styles.xml | 18 + .../app/src/profile/AndroidManifest.xml | 7 + .../example/android/build.gradle.kts | 24 + .../example/android/gradle.properties | 6 + .../gradle/wrapper/gradle-wrapper.properties | 5 + .../example/android/settings.gradle.kts | 26 + .../plugin_integration_test.dart | 21 + .../example/lib/main.dart | 58 + .../example/pubspec.lock | 291 + .../example/pubspec.yaml | 85 + .../example/test/widget_test.dart | 27 + .../lib/busymax_android_platform.dart | 325 + .../busymax_android_platform/pubspec.yaml | 69 + pubspec.lock | 352 +- pubspec.yaml | 7 +- test/architecture/platform_boundary_test.dart | 25 + test/db/app_database_test.dart | 2 +- .../android/android_adaptive_app_test.dart | 194 + .../android_notification_policy_test.dart | 57 + .../android_platform_channel_test.dart | 81 + .../android_settings_persistence_test.dart | 26 + tool/android/build_release.sh | 53 + tool/android/check_prerequisites.sh | 86 + tool/android/inspect_artifacts.sh | 70 + tool/android/run.sh | 25 + tool/android/verify.sh | 30 + tool/check_platform_boundaries.dart | 69 +- tool/generate_android_assets.dart | 53 + 135 files changed, 15809 insertions(+), 4286 deletions(-) create mode 100644 .github/workflows/flutter-android.yml create mode 100644 .run/BusyMax Android.run.xml create mode 100644 android/.gitignore create mode 100644 android/app/build.gradle.kts create mode 100644 android/app/proguard-rules.pro create mode 100644 android/app/src/debug/AndroidManifest.xml create mode 100644 android/app/src/main/AndroidManifest.xml create mode 100644 android/app/src/main/kotlin/io/busystack/busymax/MainActivity.kt create mode 100644 android/app/src/main/res/drawable-v21/launch_background.xml create mode 100644 android/app/src/main/res/drawable/ic_launcher_monochrome.xml create mode 100644 android/app/src/main/res/drawable/ic_notification.xml create mode 100644 android/app/src/main/res/drawable/launch_background.xml create mode 100644 android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml create mode 100644 android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml create mode 100644 android/app/src/main/res/mipmap-hdpi/ic_launcher.png create mode 100644 android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png create mode 100644 android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png create mode 100644 android/app/src/main/res/mipmap-mdpi/ic_launcher.png create mode 100644 android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png create mode 100644 android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png create mode 100644 android/app/src/main/res/mipmap-xhdpi/ic_launcher.png create mode 100644 android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png create mode 100644 android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png create mode 100644 android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png create mode 100644 android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png create mode 100644 android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png create mode 100644 android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png create mode 100644 android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png create mode 100644 android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png create mode 100644 android/app/src/main/res/values-night/styles.xml create mode 100644 android/app/src/main/res/values/colors.xml create mode 100644 android/app/src/main/res/values/styles.xml create mode 100644 android/app/src/main/res/xml/backup_rules.xml create mode 100644 android/app/src/main/res/xml/data_extraction_rules.xml create mode 100644 android/app/src/profile/AndroidManifest.xml create mode 100644 android/build.gradle.kts create mode 100644 android/busymax.android.properties.example create mode 100644 android/gradle.properties create mode 100644 android/gradle/wrapper/gradle-wrapper.jar create mode 100644 android/gradle/wrapper/gradle-wrapper.properties create mode 100755 android/gradlew create mode 100644 android/gradlew.bat create mode 100644 android/key.properties.example create mode 100644 android/settings.gradle.kts create mode 100644 docs/android_implementation_status.md create mode 100644 docs/android_provider_parity.md create mode 100644 docs/android_release.md create mode 100644 docs/android_setup.md create mode 100644 docs/android_verification.md create mode 100644 integration_test/android_platform_smoke_test.dart create mode 100644 lib/main_android.dart create mode 100644 lib/src/android/android_account_gate.dart create mode 100644 lib/src/android/android_app.dart create mode 100644 lib/src/android/android_authorization.dart create mode 100644 lib/src/android/android_background.dart create mode 100644 lib/src/android/android_notifications.dart create mode 100644 lib/src/android/android_time_zone.dart create mode 100644 lib/src/android/presentation/android_schedule_screen.dart create mode 100644 lib/src/android/presentation/android_settings_screen.dart create mode 100644 lib/src/android/presentation/android_tasks_screen.dart create mode 100644 lib/src/core/auth/account_token_broker.dart create mode 100644 lib/src/features/notifications/notification_reconciler.dart create mode 100644 packages/busymax_android_platform/.gitignore create mode 100644 packages/busymax_android_platform/.metadata create mode 100644 packages/busymax_android_platform/CHANGELOG.md create mode 100644 packages/busymax_android_platform/LICENSE create mode 100644 packages/busymax_android_platform/README.md create mode 100644 packages/busymax_android_platform/analysis_options.yaml create mode 100644 packages/busymax_android_platform/android/.gitignore create mode 100644 packages/busymax_android_platform/android/build.gradle.kts create mode 100644 packages/busymax_android_platform/android/settings.gradle.kts create mode 100644 packages/busymax_android_platform/android/src/main/AndroidManifest.xml create mode 100644 packages/busymax_android_platform/android/src/main/kotlin/io/busystack/busymax_android_platform/BusymaxAndroidPlatformPlugin.kt create mode 100644 packages/busymax_android_platform/android/src/test/kotlin/io/busystack/busymax_android_platform/BusymaxAndroidPlatformPluginTest.kt create mode 100644 packages/busymax_android_platform/example/.gitignore create mode 100644 packages/busymax_android_platform/example/README.md create mode 100644 packages/busymax_android_platform/example/analysis_options.yaml create mode 100644 packages/busymax_android_platform/example/android/.gitignore create mode 100644 packages/busymax_android_platform/example/android/app/build.gradle.kts create mode 100644 packages/busymax_android_platform/example/android/app/src/debug/AndroidManifest.xml create mode 100644 packages/busymax_android_platform/example/android/app/src/main/AndroidManifest.xml create mode 100644 packages/busymax_android_platform/example/android/app/src/main/kotlin/io/busystack/busymax_android_platform_example/MainActivity.kt create mode 100644 packages/busymax_android_platform/example/android/app/src/main/res/drawable-v21/launch_background.xml create mode 100644 packages/busymax_android_platform/example/android/app/src/main/res/drawable/launch_background.xml create mode 100644 packages/busymax_android_platform/example/android/app/src/main/res/mipmap-hdpi/ic_launcher.png create mode 100644 packages/busymax_android_platform/example/android/app/src/main/res/mipmap-mdpi/ic_launcher.png create mode 100644 packages/busymax_android_platform/example/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png create mode 100644 packages/busymax_android_platform/example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png create mode 100644 packages/busymax_android_platform/example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png create mode 100644 packages/busymax_android_platform/example/android/app/src/main/res/values-night/styles.xml create mode 100644 packages/busymax_android_platform/example/android/app/src/main/res/values/styles.xml create mode 100644 packages/busymax_android_platform/example/android/app/src/profile/AndroidManifest.xml create mode 100644 packages/busymax_android_platform/example/android/build.gradle.kts create mode 100644 packages/busymax_android_platform/example/android/gradle.properties create mode 100644 packages/busymax_android_platform/example/android/gradle/wrapper/gradle-wrapper.properties create mode 100644 packages/busymax_android_platform/example/android/settings.gradle.kts create mode 100644 packages/busymax_android_platform/example/integration_test/plugin_integration_test.dart create mode 100644 packages/busymax_android_platform/example/lib/main.dart create mode 100644 packages/busymax_android_platform/example/pubspec.lock create mode 100644 packages/busymax_android_platform/example/pubspec.yaml create mode 100644 packages/busymax_android_platform/example/test/widget_test.dart create mode 100644 packages/busymax_android_platform/lib/busymax_android_platform.dart create mode 100644 packages/busymax_android_platform/pubspec.yaml create mode 100644 test/platform/android/android_adaptive_app_test.dart create mode 100644 test/platform/android/android_notification_policy_test.dart create mode 100644 test/platform/android/android_platform_channel_test.dart create mode 100644 test/platform/android/android_settings_persistence_test.dart create mode 100755 tool/android/build_release.sh create mode 100755 tool/android/check_prerequisites.sh create mode 100755 tool/android/inspect_artifacts.sh create mode 100755 tool/android/run.sh create mode 100755 tool/android/verify.sh create mode 100644 tool/generate_android_assets.dart diff --git a/.github/workflows/flutter-android.yml b/.github/workflows/flutter-android.yml new file mode 100644 index 00000000..a5a1b97a --- /dev/null +++ b/.github/workflows/flutter-android.yml @@ -0,0 +1,61 @@ +name: Flutter Android + +on: + workflow_dispatch: + pull_request: + branches: [main] + push: + branches: [main] + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + build: + runs-on: ubuntu-24.04 + + steps: + - name: Check out repository + uses: actions/checkout@v7 + + - name: Set up JDK 17 + uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: '17' + + - name: Set up Flutter + uses: subosito/flutter-action@v2 + with: + channel: stable + flutter-version: '3.47.2' + cache: true + + - name: Install Android API and Build Tools 37 + run: >- + sdkmanager + 'platforms;android-37.0' + 'build-tools;37.0.0' + + - name: Verify source, Dart, Flutter, Android, and native plugin tests + run: tool/android/verify.sh + + - name: Build explicitly test-signed release APK and AAB + run: >- + tool/android/build_release.sh --all --test-signing + --allow-unconfigured-providers + + - name: Inspect test-signed artifacts + run: tool/android/inspect_artifacts.sh + + - name: Upload clearly labeled non-production artifacts + uses: actions/upload-artifact@v7 + with: + name: busymax-android-test-signed-not-for-distribution + path: | + build/app/outputs/flutter-apk/app-release.apk + build/app/outputs/bundle/release/app-release.aab + build/android/android-artifact-inspection.txt + if-no-files-found: error + retention-days: 7 diff --git a/.metadata b/.metadata index da084209..ddeab23e 100644 --- a/.metadata +++ b/.metadata @@ -4,7 +4,7 @@ # This file should be version controlled and should not be manually edited. version: - revision: "f6ff1529fd6d8af5f706051d9251ac9231c83407" + revision: "d3b14c876900e553bc736ca19295fc09e3853e8e" channel: "stable" project_type: app @@ -21,6 +21,9 @@ migration: - platform: windows create_revision: ad70ec4617166f1c38e5d2bfd388af71fda14f06 base_revision: ad70ec4617166f1c38e5d2bfd388af71fda14f06 + - platform: android + create_revision: d3b14c876900e553bc736ca19295fc09e3853e8e + base_revision: d3b14c876900e553bc736ca19295fc09e3853e8e # User provided section diff --git a/.run/BusyMax Android.run.xml b/.run/BusyMax Android.run.xml new file mode 100644 index 00000000..9c7b364e --- /dev/null +++ b/.run/BusyMax Android.run.xml @@ -0,0 +1,7 @@ + + + + diff --git a/NOTICE b/NOTICE index 0c888af2..ab1dd665 100644 --- a/NOTICE +++ b/NOTICE @@ -9,3 +9,12 @@ Source repository: https://github.com/busystack/busymax The BusyMax name and logo are identifying marks of BusyStack. This license does not grant permission to use the BusyMax name or logo to imply endorsement, sponsorship, or official status for modified versions. + +The Android build includes Microsoft Authentication Library for Android +(MSAL), licensed under the MIT License; Flutter Workmanager, licensed under the +MIT License; and flutter_local_notifications, licensed under the BSD 3-Clause +License. It also links Google Play services libraries, which are distributed +under Google's applicable Android SDK and API terms. Complete Flutter package +license text is available through Flutter's license registry in built apps; +Gradle dependency and license records must be retained with distributed +Android artifacts. diff --git a/README.md b/README.md index 2bc727cd..7a583b2d 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,14 @@ # BusyMax -BusyMax is a beta calendar and task manager for Linux and Windows. It brings -events, tasks, reminders, and day, week, month, year, and agenda views into one -desktop application. +BusyMax is a beta calendar and task manager for Android, Linux, and Windows. It +brings events, tasks, reminders, and day, week, month, year, and agenda views +into one application. Linux uses a Yaru/GTK desktop composition with XDG integrations. Windows uses a separate Fluent composition with a native Windows title bar, tray, -notifications, and packaged startup integration. Provider capabilities differ; +notifications, and packaged startup integration. Android uses a separate +adaptive Material 3 composition, native account authorization, WorkManager, +Android notifications, and the Storage Access Framework. Provider capabilities differ; see the [provider support matrix](docs/provider_support_matrix.md) before relying on a particular task field or operation. @@ -29,8 +31,8 @@ relying on a particular task field or operation. permissions granted for a collection. - Local caching for offline viewing. Supported changes can be queued for some providers; offline behavior is not identical across every integration. -- Desktop reminders, tray actions, import/export, themes, keyboard navigation, - and localized interfaces. +- Platform-native reminders, Android document import/export, desktop tray + actions, themes, keyboard navigation, and localized interfaces.
More Linux screenshots @@ -67,7 +69,9 @@ test artifacts, not ordinary user downloads. Packaged users do not need Flutter, Visual Studio, Linux development libraries, or their own OAuth registration. In a configured build: -- Google and Microsoft accounts connect through browser-based sign-in. +- Desktop Google and Microsoft accounts connect through browser-based sign-in. + Android uses Google Play services authorization for Google and MSAL's + registered browser redirect for Microsoft. - Apple iCloud Calendar uses an Apple app-specific password; Apple Reminders is not supported. See [Connect Apple iCloud Calendar](docs/apple_icloud_setup.md). - Nextcloud authorizes BusyMax in the default browser and requires a compatible @@ -80,6 +84,24 @@ use the [Google OAuth registration guide](docs/google_setup.md) or differences and limitations are summarized in the [provider support matrix](docs/provider_support_matrix.md). +### Android development build + +Android support targets API 37 with a minimum API of 24 and uses the explicit +entrypoint `lib/main_android.dart`. It requires Flutter 3.47.2, bundled Dart +3.13.2, JDK 17, Android platform 37, and Build Tools 37.x. + +```sh +tool/android/check_prerequisites.sh +cp android/busymax.android.properties.example android/busymax.android.properties +tool/android/run.sh --device DEVICE_ID +``` + +The copied public configuration needs real Microsoft and certificate-specific +provider registrations before those sign-in flows work. It contains no client +secret. See [Android setup](docs/android_setup.md), [Android release](docs/android_release.md), +and the [Android verification report](docs/android_verification.md). Generated +test-signed artifacts are not production releases. + ## Documentation Account, provider, and data information: @@ -88,10 +110,14 @@ Account, provider, and data information: - [Apple iCloud Calendar setup](docs/apple_icloud_setup.md) - [Nextcloud Calendar and Tasks setup](docs/nextcloud_setup.md) - [Privacy and data map](docs/privacy_data_map.md) +- [Android setup and OAuth registration](docs/android_setup.md) +- [Android provider parity](docs/android_provider_parity.md) Development and maintenance: - [Development setup for Linux and Windows](docs/development.md) +- [Android release](docs/android_release.md), [verification](docs/android_verification.md), + and [implementation status](docs/android_implementation_status.md) - [Google developer OAuth registration](docs/google_setup.md) and [Microsoft developer OAuth registration](docs/microsoft_setup.md) - [iCalendar and DAV data model](docs/icalendar_data_model.md) diff --git a/analysis_options.yaml b/analysis_options.yaml index de5a8d23..e0186ba8 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -6,6 +6,9 @@ analyzer: - tool/google_tasks_discovery/cache/** - windows/** - linux/** + - android/** + - packages/**/example/** + - packages/busymax_android_platform/example/** linter: rules: diff --git a/android/.gitignore b/android/.gitignore new file mode 100644 index 00000000..00743b94 --- /dev/null +++ b/android/.gitignore @@ -0,0 +1,16 @@ +/.gradle +/build +/captures/ +/local.properties +/busymax.android.properties +/key.properties +*.jks +*.keystore +GeneratedPluginRegistrant.java +.cxx/ + +# Remember to never publicly share your keystore. +# See https://flutter.dev/to/reference-keystore +key.properties +**/*.keystore +**/*.jks diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts new file mode 100644 index 00000000..d801cc0a --- /dev/null +++ b/android/app/build.gradle.kts @@ -0,0 +1,163 @@ +import java.util.Properties +import java.net.URLEncoder +import java.nio.charset.StandardCharsets + +plugins { + id("com.android.application") + // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. + id("dev.flutter.flutter-gradle-plugin") +} + +android { + namespace = "io.busystack.busymax" + compileSdk = 37 + ndkVersion = flutter.ndkVersion + + buildFeatures { + resValues = true + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + isCoreLibraryDesugaringEnabled = true + } + + val publicConfig = Properties() + val publicConfigFile = rootProject.file("busymax.android.properties") + if (publicConfigFile.isFile) { + publicConfigFile.inputStream().use(publicConfig::load) + } + val msalClientId = publicConfig.getProperty("microsoft.clientId", "").trim() + val msalAuthorityTenant = publicConfig.getProperty("microsoft.authorityTenant", "common").trim() + val msalSignatureHash = publicConfig.getProperty("microsoft.signatureHash", "busymax-not-configured").trim() + val generatedBusyMaxResources = layout.buildDirectory + .dir("generated/busymax/res") + .get() + .asFile + sourceSets.getByName("main").res.srcDir(generatedBusyMaxResources) + + val signingProperties = Properties() + val signingPropertiesFile = rootProject.file("key.properties") + if (signingPropertiesFile.isFile) { + signingPropertiesFile.inputStream().use(signingProperties::load) + } + + signingConfigs { + if (signingPropertiesFile.isFile) { + create("release") { + storeFile = file(signingProperties.getProperty("storeFile")) + storePassword = signingProperties.getProperty("storePassword") + keyAlias = signingProperties.getProperty("keyAlias") + keyPassword = signingProperties.getProperty("keyPassword") + } + } + } + + defaultConfig { + applicationId = "io.busystack.busymax" + minSdk = 24 + targetSdk = 37 + // Uses the version code from pubspec.yaml. When using split APKs, 1000 * ABI_VERSION + // is added automatically by Flutter. (https://developer.android.com/studio/build/configure-apk-splits#configure-APK-versions) + // You can force using the value of versionCode by specifying the `-P force-version-code-ignoring-abi=true` + // flag during build. + versionCode = flutter.versionCode + versionName = flutter.versionName + manifestPlaceholders["busymaxMsalRedirectHost"] = applicationId!! + manifestPlaceholders["busymaxMsalSignatureHash"] = msalSignatureHash + resValue("string", "busymax_msal_client_id", msalClientId) + resValue("string", "busymax_msal_authority_tenant", msalAuthorityTenant) + } + + buildTypes { + release { + isMinifyEnabled = true + isShrinkResources = true + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro", + ) + signingConfig = when { + signingPropertiesFile.isFile -> signingConfigs.getByName("release") + providers.gradleProperty("busymaxTestSigning").orNull == "true" -> + signingConfigs.getByName("debug") + else -> null + } + } + } +} + +val generateBusyMaxAndroidConfiguration by tasks.registering { + val clientId = providers.provider { + val properties = Properties() + val file = rootProject.file("busymax.android.properties") + if (file.isFile) file.inputStream().use(properties::load) + properties.getProperty("microsoft.clientId", "").trim() + } + val tenant = providers.provider { + val properties = Properties() + val file = rootProject.file("busymax.android.properties") + if (file.isFile) file.inputStream().use(properties::load) + properties.getProperty("microsoft.authorityTenant", "common").trim() + } + val signature = providers.provider { + val properties = Properties() + val file = rootProject.file("busymax.android.properties") + if (file.isFile) file.inputStream().use(properties::load) + properties.getProperty("microsoft.signatureHash", "").trim() + } + val outputDirectory = layout.buildDirectory.dir("generated/busymax/res/raw") + inputs.property("microsoftClientId", clientId) + inputs.property("microsoftAuthorityTenant", tenant) + inputs.property("microsoftSignatureHash", signature) + outputs.dir(outputDirectory) + doLast { + val output = outputDirectory.get().file("busymax_msal_config.json").asFile + output.delete() + if (clientId.get().isNotEmpty() && signature.get().isNotEmpty()) { + output.parentFile.mkdirs() + val encodedSignature = URLEncoder.encode( + signature.get(), + StandardCharsets.UTF_8, + ) + output.writeText( + """{ + "client_id": "${clientId.get()}", + "redirect_uri": "msauth://io.busystack.busymax/$encodedSignature", + "authorization_user_agent": "BROWSER", + "account_mode": "MULTIPLE", + "broker_redirect_uri_registered": false, + "logging": {"pii_enabled": false, "log_level": "WARNING"}, + "authorities": [{ + "type": "AAD", + "audience": { + "type": "AzureADandPersonalMicrosoftAccount", + "tenant_id": "${tenant.get()}" + }, + "default": true + }] +} +""", + ) + } + } +} + +tasks.named("preBuild").configure { + dependsOn(generateBusyMaxAndroidConfiguration) +} + +kotlin { + compilerOptions { + jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17 + } +} + +flutter { + source = "../.." +} + +dependencies { + coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.5") +} diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro new file mode 100644 index 00000000..f9e24b29 --- /dev/null +++ b/android/app/proguard-rules.pro @@ -0,0 +1,18 @@ +# Flutter plugins ship their own consumer rules. Keep only the entrypoints that +# Android or MSAL invokes by class name after shrinking. +-keep class io.flutter.plugins.GeneratedPluginRegistrant { *; } +-keep class com.microsoft.identity.client.BrowserTabActivity { *; } + +# Nimbus exposes optional PEM/Bouncy Castle helpers that MSAL's Android token +# path does not call. Bouncy Castle is intentionally not bundled just to +# satisfy those unused signatures. +-dontwarn org.bouncycastle.asn1.ASN1Encodable +-dontwarn org.bouncycastle.asn1.pkcs.PrivateKeyInfo +-dontwarn org.bouncycastle.asn1.x509.AlgorithmIdentifier +-dontwarn org.bouncycastle.asn1.x509.SubjectPublicKeyInfo +-dontwarn org.bouncycastle.cert.X509CertificateHolder +-dontwarn org.bouncycastle.cert.jcajce.JcaX509CertificateHolder +-dontwarn org.bouncycastle.jce.provider.BouncyCastleProvider +-dontwarn org.bouncycastle.openssl.PEMKeyPair +-dontwarn org.bouncycastle.openssl.PEMParser +-dontwarn org.bouncycastle.openssl.jcajce.JcaPEMKeyConverter diff --git a/android/app/src/debug/AndroidManifest.xml b/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 00000000..399f6981 --- /dev/null +++ b/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml new file mode 100644 index 00000000..091ebb44 --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,100 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/kotlin/io/busystack/busymax/MainActivity.kt b/android/app/src/main/kotlin/io/busystack/busymax/MainActivity.kt new file mode 100644 index 00000000..fda5349b --- /dev/null +++ b/android/app/src/main/kotlin/io/busystack/busymax/MainActivity.kt @@ -0,0 +1,5 @@ +package io.busystack.busymax + +import io.flutter.embedding.android.FlutterActivity + +class MainActivity : FlutterActivity() diff --git a/android/app/src/main/res/drawable-v21/launch_background.xml b/android/app/src/main/res/drawable-v21/launch_background.xml new file mode 100644 index 00000000..f74085f3 --- /dev/null +++ b/android/app/src/main/res/drawable-v21/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/android/app/src/main/res/drawable/ic_launcher_monochrome.xml b/android/app/src/main/res/drawable/ic_launcher_monochrome.xml new file mode 100644 index 00000000..7f21ca01 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_launcher_monochrome.xml @@ -0,0 +1,8 @@ + + + + diff --git a/android/app/src/main/res/drawable/ic_notification.xml b/android/app/src/main/res/drawable/ic_notification.xml new file mode 100644 index 00000000..8779f57d --- /dev/null +++ b/android/app/src/main/res/drawable/ic_notification.xml @@ -0,0 +1,10 @@ + + + + diff --git a/android/app/src/main/res/drawable/launch_background.xml b/android/app/src/main/res/drawable/launch_background.xml new file mode 100644 index 00000000..304732f8 --- /dev/null +++ b/android/app/src/main/res/drawable/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 00000000..69fc468d --- /dev/null +++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 00000000..69fc468d --- /dev/null +++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..79a4b4057a04cfe98adec6258e2f8bbfa05081bb GIT binary patch literal 6938 zcmV+#8|CDQP)@XMs5kXmG8;mFj%78&-8IdIfWS5ZykO^V}3=&{e zKoC$+b`ZiakU)$Cg+Pd8>7@5P>9joh~|Csvy>R0Qls(bGF&Ue0B z)otMar*S9aAN>FT$OjzfpU`tuoc>V&g+cMQ`8elDa1Sy*}!~jG9m|!pg zF!vY_{SKcLSf3O?YNIrFhFlV)&Ww)?CrLGQLZG_`#nR$iu_#wN0YKITOLvzD0k^}Z zPXZvKEK9%uiPIA!TN7H1Y;`%EfF!BGH7P!9@)2C0n~(rN-QS0S*4+o=+pKFNM(%@IkDPP5?lMC zS}XvD3Pc#FN}x%05sfU4@{X+0hs*MfNtaD3Abfi=YI^~shLE=|JN|@Zcdm*|CSf4d z^^se&ITp5e`9I{G;PUoPJb!r&{&Ii1BFl8yVhYA7C~VY{=6x3=JMvc+ zXU*hvbdw9xcGIVi3Q$;Q;CC+3C!Uxnbm_z(T7{n1TwoS;cY>OgP4tlqx8jHeTXD;j z7Kn?4!%jfMhz-su7%b4g%~wTNta=h zh)IxaImbu>^AoRc&0!>WTmGoJ?O_Z49)K#PZ3)bN@!#Ljsvm#8K8C=GnHGV?J)LD2 zy{fSV(RhhnKi#bv1_v(M+##>ROoFr9yRh-81eTpMLBhi2Y)g;?lN^w|*+zs%lm8xn zCjSJ#@yqSSWzT$^u=_9ojT26>>SGf5KAjL!2^4Sp_O1?4D;Q4emc?-Y8TE+7gXc@2 z2(Wl+E7ElobKmHZ=TAE(k7@0B{8w`=m_eN|65Hc35I?G^W**g^&u;0Vm{e#^T`+I2>0@b;2oZ2({47ii0|S zHnjyO|DhMPyV&yBIb9cH%*jqQeZvFQUKWx~A0UFZ< zBU;;hm@<(0?ou7So;UJE0pvj0+ipt9Ngfjn_kAI35GNnY#?Ry{!t&F!8+&ro3*d zQNv|hYH{NpJ;-de9R?}d72wf#KFSyUVC}(bSf}@4~+gU#C*oNxc!7i`%em8;hpcIOaBvna4Yi zf9czkon_lQk~QKyu7sZqJE8zcC^)ja?0v{sU_THgWpICkDhD7KDUUgwlw(nym8BJ zh$J+eQ}-%t38({-1TAC>#JWLr92h+_mtn&z-?D#uh|4Aq2gxWS1di;)n&@eV%#8f< z`AWU1Ht61sozS$P(qu;nez13QMPSCta+Q{HopV@3VM3Yrp}4ATGm6C`ZtUF|B9~fu zip3oVVA0p!z`HAYT*qxW3>09w;5Z;TY>ScD?0q9A7T)A9?8@ztcBNzqFi3*4PckzS zC8@+%`rvIp&|WUW=S*#u*{M>^6$WZNaQx6B<*A%`Y;yMrMrl!s`E7r}XV0v~oNK4Z zXPNi#e&C$rl!k}o@e(L*__<-HX1rPCfOY-REjRU>Ypzg3Of`L}$T;Ar`@hlJiu;7q zRWLf{yiq*2r26j;tIGFdGPJnL8n;0Kqo}0$m+tPyAMVNu4_qsUeF0{KhC!oaX-(O>4$R%D1-lxV$b=ZUI80JgGK`Q|+gWAfggjcs^(JPipq?%d`!XbiYzvy;(8F?xM_7rNex;LhVF zm6^%$SW0MGpz&|2+N+9m3jsPQkhg5Rd(CoEHS@s8hCdsmA@)H%@2_jX{GD15PcmHhuH%WC z_q6cC_D=jqa|^UcN$kebCa#2x$DXh#i*4bDZs?ljhEvJo&24@{0yCCT4Cs~%%m^R+ z)sXStwXk^($h)tb;sFM6z~GZmk3J08K%r<^G4irCV{f34Ix;!%r#Pt|H!Rd+G^fdW z>(Z6osBU!J{MB7fC4bY@mWs^rmNs!iiaTY3A0EoS>$Xe_nl2D5f3OSp%zLvGHYkN( z1*0hU6?qQp$*X%?wpIYO^P=4XoG2!7Q*ynO<^go^K1|@Xs1=VvN5P7K7_^D;SIp+U z^NcsmiD#ZBZsi_!zLiq=qS;N*!Etqar`#*{U(((QW&^j*Xek59>2cF(2=P3NWd2AezKL`ex89x#e3qip_`_4Jnr&|^c(17-m*%rw$8f(RRi&Vdaw zW{uLQn<%*>Ob2BQjHN+X2AU~cc+QTHx@cqKtBr&_Gt>>sZW`AC2f-q^erhZ1EO7OJ z!y?T9zTe)7U(BA2Vz2I=&)slxdYSW5NKi&`Wk-F$t&8k*R>N{c;kw;P_#IRGGP#roGv#_XMA*+O#nBy!5i^V|MC`K8O;6Ofh zM|1Dmbfj7ie;(V|1fnxMylEI|cl2;9GpQ@098Up@rncbt+lR30v3YsZ(^vL@bKvgQ z7BF2AWuv!4Zo4}B(qLu~ zSKiz0n{c$7J2XSCM@@V0C^vQ#_Oln^@!Kka!3?hAx^j;dEYZ^}7 zwGMgoW7%Kppb>3M&l6_YGv`jkbKtnJzXMjzu?64m?1Ua;SkhSSi|HOGaUAuZNfbv* z*@@`D{HC{Iqv-pYfCW(4<6Xz%Ma;Y6CTNiq3XH?%fXzkl62udASa4Zx!&~d0`_+RF z9sB((vK(mPK%rta?=q7A1DT@}Rr+{}3s-uo-sUe^JhT6_8^^xX@(1L(~ zC}6ouXPscb$xWEu3f#Y<7j==wGVe5&;nWkrJyR#6^{7!CwKOXq;;`$y%Gm*DaHEZ% z9J0lC)+M9o>5Hp73OLbP4VTWp5kh2&OlX_qY+=HCu!L=mHSad`}cM8_{xGf@5p1ISjIrcbB4XwNfGo)(_Z(h2XU5ELT({!6 zxj*&Z*s2;NGBQy?Aqq$V=D5e80_cL8GOBRG7dg5z-B`1-Y22EQ@?h5aaia(mQ)-Nu z{c#*#ZWO5!T4LY4%TLFIswNbexGr}a?|>lKBPuMp?z4tV1pIRpRsm@Z$Y>rwS}DY8 ztPB|#sMYH0RWNit1d<+Pkyf^kV9JChOgjd6=*ezOAWdarE&8S8nC(*WiJ?fif97va#?q#bJi!hZC0}CV}v0;_H(*XYG3rKl=I$Xa4|5g526?S{l;9Q`feiN`v@hHHZZVO$9{ z+21TE%$(wjN4H_lmkyEHso-X(K*Lc=<A<2=gGT-&ryfthtr&Ng3A!^;=zkf6(R^x(Fd6~BB^8$cND zy0sf|BM}(U^D+`u2Hm&O0X%s7S-uN)8ctjWxH^KMu2{@Hhb!l}+Eb(^U$iLwg$NQu z97Yka3V>!-p~^O3xV6~&530p9ngO>2NCg%pEu_^rVioYx)NY`4vJ~O~P^5CE^FuFe zIt>ly&f66QK8o8Gtwr33mkLWaYYi9sao6ILk%-1*gFPTb!{yT5N0pTAdm9jEsh-1u z_h0V<%ZWE3+_Yd90a5^TD+NLw_V~3}zz4ZI5-bOk#h|e)vJ8kZfF(2NT@yiE692%> zlVNUQc=5ACXoxg80O*yO67Wn!(J#FOTuQIPWmkS$zHAiZI3~{{6-yueh?YzqDLG)2Trdr=M>&TH$<1QT^MGzN%4g?p8^oZl5vj$o2j@5S zXI@*e^3Bg5Uz7nXYO#F9T8w+O*uUy0@Ao_U;4-l#p(M_dx=ZgS^<)xiUtri+--F0! znx*8c0xg*mViF05Tu8^X&~K>E8YPtn)3ck!+;jX?PO#$fPQ>&k7>1MPxzZM=To>0Nf>%+iE1FegAh%w0=;=gkAZooaj2tn&m6WFsH>kse6 z0$2)0P{<%L9*&7GV1$K8D(Xnk=K zhWHSwPppQa8D#?P*K|rqLKSYsV2X9=VFUmkOPFH*m}m5yY$eVe&JE))Po=>0#u8Ip zwtK9X9nBSx0_SivwtV}ljqASmW3?9Z*(1Wz2L}!yJ~iQ7zz;Dv;K<$q@o!eG)xv%Z z=uC8B=Q+)R#`~e8S727Ce377gA%$RulBpX(zw`9H-AE=H!8HX4_c;S!O^IWg0*Z3n zBLGmP1EX4bi1C`@``FOEGp09r>~H{ae#jJoa76-q<`Css5qwrIJJ@O8<_!zy136Nu z7L)JAcEKL|@2>5(&uYHvNgoK8IsU~7QRwNM!zKEP0J^1g$**4lslV?8ESQ+QM@fxwmCaC(TBWjSvtzCPXXE1(Y@IOd$zKzL~(Q5H*+ zhlVATGQp%2k1QQTGF~UciH1O7UWXFmh7Av;_mcj2zv~}({EU7kr|)CiMh>tGf*gYM z5lrG%<@aKBYyu`Q@!oP&)+gdIlbB((?A2g2%vfz~derX`gkqYwp%-G7I!gfsN6*WR z*!;4EgU{NANmca$BUw}mPj3*bFV~A@_tt}wMyL0Qm!f&uGX%pdc-*%qV0_vC)O?YV zNI$qyaB8uFS&eG(>W4eE*!zK6%(_v5srd1(gXN10RnQUpFJ@DQxNbUQWv1;iI zd}@D(!n`b|X(+=v@$dC4_cIL9biBND0!COLzIvX9Nj3E)GYChNyGDEP?9Zz}^aeiz zG0xqvBBgu#_b2X9iU*XltpfJOyQf{4-S*T=4ADHeWr`v-nMm7O-Vn7Ijmlaq5_s+7 zc|xH8Gsap%CZ%V>UCB7YDo-PtG%GHMzt3S10 z-qMO(j#P;6e{?$y%|gDw3>bC<5xW;r25j~S=Q9YUuRS|4fMzs+a00?92u}%BPgSvO zc9fd1EwHG0cy9V$1)ghsquPF+d*CSSk)jRnft`$EL!rD6SSb_?l0ZUBz$T?{bR9s6 z)F!6!AX9xEBta(m>09I@3s1i`@lb-upam|5HUw*|R4PqLHZNJDL#H+jI)WGlI#j2? zLJac4w2Sp)ickbT5f&H20CqOdQ_vWV$629}qXjW0fpo{`vNijhA6tCk@v$>b4}=yW zE?A|o;y^&Sz3dni+R)N1b38lY>zyzDuANZrhNyFUZb%Ul@GxqMrU+#! zSDXuU6u*0;=|IM#qf`zPiPWvxsbq?hT!Cr~k~I6eo+V$PV<3KQ%f=p z*dU*20BvG)hSkB+g@}urrl1*CG{#J`NKG&cNZTh4HS9Ypa>&8gPI%&0UzDXTd0DPh zx|Fd1#5bHwD`iTRH34JjEct_PG##b!La)W+uFeoMm8zCHLvlq4j2JB(M1+cgL11H9 zI4L9<^W8iugq-&yDqe$#Psaz*%xhpWL$C>qX(rVTJ;HNTyqU6SfpTj#efh<&_ow%{ zwPDlpv{si?$6x&LQOzV#m!1>mB_>rkPwZbm{F_JqwD~&=EGmXhsU^ZG8jaf#({jT} zmm)j@gE5)DJHu>Yp|%I6vHqAv1tt`iVz8i-Z$*RnT!uHm(k$U~Ddj2XjHgmGm)%AI zYUgZjpWQwEGYrM_Z7sbk5=KoaRMh&elD-5&vs|aL_5gmnX2$;F>oD6%+eHyh$#gZ< zGFcxOH=HQY>%IsIgwvS7d=NmDfKs<9oMBnkkDY`*u{zj7pBT;NbO}^4L4`hZ&7kM0!lU}S6{NsHMv0Zm(>M*D~WD+h59M9vN_w<(UoY%eWkyj|9MJffDSjlAC)^Y_(vO;Gf zE-(fKA{^+T$}{uwXc!-yzXu>x2s@^{ZnuFB@vcN0Ml-92MJa5Zg0LiGj3v`7m&=O8 zV&^Pa*LTeRx~|jM+6+chX<4+x=g%7O`AEBhz*=o}hZJD8tEl zWh2sdRMi)2P~PM^@h(1qX(EMswt!KGO_V27(iT{tmPHM7!N#5U zv|!d>F{5KyEHbfFL%C^t)b62ozq9kf97e{LpjgF#3oclGI1R{fGQ2Y3BZbrU0%)w8 zDPgw}jxA`FwqT+gb~IOz{wb#s&KRXdE^I;ETJZk#JrfbGn>I_?nrRp;YDNt`pS6e$ zL@Y;VVEJj=b-{u)Lo#g(ohf(09|st3+8+OTfrrA*`@)4eCM?Z|P{SKwN|PzR(?~K# zInnhz(J5n=#lVfM9DvbtpeZ6g{Ix9G{Z9(SHjF5Z*)|j4=uSA%A`F(3tm&)-%ts2R zO4IiG*A(Gw=RF_+GU77Hut}qwvJ~f(LDxw-Wo0s>Zk_J^@t?+1>1swY z8Afc*2n?pAZ3b*(Kz}#^LV1pl1&lXs?|*JGmOhF1v{GUQFii}q>XTrJKd{t<(PWu| zR*eX8N?Re<&PW0>d9KOt1)T4f`(!wY*Wnvf){ONG+hy8O|38R_mdYgc34J^0#@l=P gXZSC8|J2j}0S$hRhAfGe5dZ)H07*qoM6N<$f>;(RSO5S3 literal 0 HcmV?d00001 diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png new file mode 100644 index 0000000000000000000000000000000000000000..597c3146c8e89c26c7578e8ba94992c82fb012ea GIT binary patch literal 3943 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 literal 0 HcmV?d00001 diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..be74f97b515a1562063a492b3581c119786bed5e GIT binary patch literal 3668 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 YA0;KN{o0#`uK)l507*qoM6N<$f-F=;Z2$lO literal 0 HcmV?d00001 diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png new file mode 100644 index 0000000000000000000000000000000000000000..be74f97b515a1562063a492b3581c119786bed5e GIT binary patch literal 3668 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 Yzlbe5sJrSuw*UYD07*qoM6N<$g3hUT2mk;8 literal 0 HcmV?d00001 diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000000000000000000000000000000000000..7525f4a17086a8937c2ace575dca6ea732105e97 GIT binary patch literal 6213 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 Yzlbe5sJrSuw*UYD07*qoM6N<$g3hUT2mk;8 literal 0 HcmV?d00001 diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..32218881870c25c3aedbb6bd3b54fd48e883fcce GIT binary patch literal 21095 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 CmALN! literal 0 HcmV?d00001 diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png new file mode 100644 index 0000000000000000000000000000000000000000..1a9822d74222bac9bdfa009d89d4c7a3df44435f GIT binary patch literal 32321 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 + + + + + + diff --git a/android/app/src/main/res/values/colors.xml b/android/app/src/main/res/values/colors.xml new file mode 100644 index 00000000..2fb4c8c7 --- /dev/null +++ b/android/app/src/main/res/values/colors.xml @@ -0,0 +1,4 @@ + + + #F8F1E3 + diff --git a/android/app/src/main/res/values/styles.xml b/android/app/src/main/res/values/styles.xml new file mode 100644 index 00000000..cb1ef880 --- /dev/null +++ b/android/app/src/main/res/values/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/android/app/src/main/res/xml/backup_rules.xml b/android/app/src/main/res/xml/backup_rules.xml new file mode 100644 index 00000000..abf894d5 --- /dev/null +++ b/android/app/src/main/res/xml/backup_rules.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/android/app/src/main/res/xml/data_extraction_rules.xml b/android/app/src/main/res/xml/data_extraction_rules.xml new file mode 100644 index 00000000..404dc1d3 --- /dev/null +++ b/android/app/src/main/res/xml/data_extraction_rules.xml @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/android/app/src/profile/AndroidManifest.xml b/android/app/src/profile/AndroidManifest.xml new file mode 100644 index 00000000..399f6981 --- /dev/null +++ b/android/app/src/profile/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/android/build.gradle.kts b/android/build.gradle.kts new file mode 100644 index 00000000..8294796c --- /dev/null +++ b/android/build.gradle.kts @@ -0,0 +1,27 @@ +allprojects { + repositories { + google() + mavenCentral() + maven { + url = uri("https://pkgs.dev.azure.com/MicrosoftDeviceSDK/DuoSDK-Public/_packaging/Duo-SDK-Feed/maven/v1") + } + } +} + +val newBuildDir: Directory = + rootProject.layout.buildDirectory + .dir("../../build") + .get() +rootProject.layout.buildDirectory.value(newBuildDir) + +subprojects { + val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name) + project.layout.buildDirectory.value(newSubprojectBuildDir) +} +subprojects { + project.evaluationDependsOn(":app") +} + +tasks.register("clean") { + delete(rootProject.layout.buildDirectory) +} diff --git a/android/busymax.android.properties.example b/android/busymax.android.properties.example new file mode 100644 index 00000000..63052048 --- /dev/null +++ b/android/busymax.android.properties.example @@ -0,0 +1,20 @@ +# Public Android registration values. Copy this file to +# android/busymax.android.properties and never put secrets in it. +# +# Google Identity Services uses the package name and certificate fingerprints +# registered in Google Cloud; it does not use the desktop OAuth client secret. +google.androidPackage=io.busystack.busymax +google.debugSha256= +google.releaseSha256= +google.playSha256= + +# MSAL public-client registration. microsoft.signatureHash is the Base64 +# signer hash used in the msauth redirect URI, not a secret. +microsoft.clientId= +microsoft.authorityTenant=common +microsoft.signatureHash= + +# Public links embedded in the Flutter release by tool/android scripts. +busymax.privacyPolicyUrl=https://busystack.org/privacy +busymax.supportUrl=https://busystack.org/support +busymax.homepageUrl=https://busystack.org diff --git a/android/gradle.properties b/android/gradle.properties new file mode 100644 index 00000000..5d054887 --- /dev/null +++ b/android/gradle.properties @@ -0,0 +1,7 @@ +org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError +android.useAndroidX=true +# Flutter 3.47.2 still casts the current AGP extension to the legacy variant +# API, so this one compatibility flag is required until the pinned Flutter +# Gradle plugin migrates. Kotlin compilation itself remains AGP 9 built-in. +android.newDsl=false +android.builtInKotlin=true diff --git a/android/gradle/wrapper/gradle-wrapper.jar b/android/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..13372aef5e24af05341d49695ee84e5f9b594659 GIT binary patch literal 53636 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ⅅ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 H-+%o-(+5aQ literal 0 HcmV?d00001 diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 00000000..da80f8d1 --- /dev/null +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-all.zip diff --git a/android/gradlew b/android/gradlew new file mode 100755 index 00000000..9d82f789 --- /dev/null +++ b/android/gradlew @@ -0,0 +1,160 @@ +#!/usr/bin/env bash + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS="" + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn ( ) { + echo "$*" +} + +die ( ) { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; +esac + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin, switch paths to Windows format before running java +if $cygwin ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + JAVACMD=`cygpath --unix "$JAVACMD"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=$((i+1)) + done + case $i in + (0) set -- ;; + (1) set -- "$args0" ;; + (2) set -- "$args0" "$args1" ;; + (3) set -- "$args0" "$args1" "$args2" ;; + (4) set -- "$args0" "$args1" "$args2" "$args3" ;; + (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Split up the JVM_OPTS And GRADLE_OPTS values into an array, following the shell quoting and substitution rules +function splitJvmOpts() { + JVM_OPTS=("$@") +} +eval splitJvmOpts $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS +JVM_OPTS[${#JVM_OPTS[*]}]="-Dorg.gradle.appname=$APP_BASE_NAME" + +exec "$JAVACMD" "${JVM_OPTS[@]}" -classpath "$CLASSPATH" org.gradle.wrapper.GradleWrapperMain "$@" diff --git a/android/gradlew.bat b/android/gradlew.bat new file mode 100644 index 00000000..8a0b282a --- /dev/null +++ b/android/gradlew.bat @@ -0,0 +1,90 @@ +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS= + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto init + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto init + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:init +@rem Get command-line arguments, handling Windowz variants + +if not "%OS%" == "Windows_NT" goto win9xME_args +if "%@eval[2+2]" == "4" goto 4NT_args + +:win9xME_args +@rem Slurp the command line arguments. +set CMD_LINE_ARGS= +set _SKIP=2 + +:win9xME_args_slurp +if "x%~1" == "x" goto execute + +set CMD_LINE_ARGS=%* +goto execute + +:4NT_args +@rem Get arguments from the 4NT Shell from JP Software +set CMD_LINE_ARGS=%$ + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/android/key.properties.example b/android/key.properties.example new file mode 100644 index 00000000..aa9237c5 --- /dev/null +++ b/android/key.properties.example @@ -0,0 +1,5 @@ +# Copy to android/key.properties. Keep this file and the keystore untracked. +storeFile=/absolute/path/to/busymax-upload.jks +storePassword= +keyAlias= +keyPassword= diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts new file mode 100644 index 00000000..ca378fae --- /dev/null +++ b/android/settings.gradle.kts @@ -0,0 +1,28 @@ +pluginManagement { + val flutterSdkPath = + run { + val properties = java.util.Properties() + file("local.properties").inputStream().use { properties.load(it) } + val flutterSdkPath = properties.getProperty("flutter.sdk") + require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" } + flutterSdkPath + } + + includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") + + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +plugins { + id("dev.flutter.flutter-plugin-loader") version "1.0.0" + id("com.android.application") version "9.2.0" apply false + // Flutter 3.47.2 validates this minimum even though AGP 9 performs the + // actual built-in Kotlin compilation. + id("org.jetbrains.kotlin.android") version "2.4.0" apply false +} + +include(":app") diff --git a/docs/android_implementation_status.md b/docs/android_implementation_status.md new file mode 100644 index 00000000..60bd9403 --- /dev/null +++ b/docs/android_implementation_status.md @@ -0,0 +1,40 @@ +# Android implementation status + +The Android entrypoint composes a Material 3 Schedule, Tasks, and Settings +application independently of the Yaru Linux and Fluent Windows entrypoints. +Compact windows use bottom navigation and wider windows use a navigation rail. +Schedule supports agenda, day, week, month, and year modes, date navigation, +search, source filters, details, maps handoff, and contextual creation. Mobile +event/task editors use the shared drafts and concrete provider capabilities. + +The app-owned `busymax_android_platform` plugin owns Google Identity Services, +MSAL, account binding, secure cross-engine account gates, Android system time +settings, document/content URI activation, document-tree export, external URI +handoff, local-network permission, and cold/warm activation delivery. Desktop +OAuth loopback flows are not used by Android. + +WorkManager registers unique 15-minute periodic work with a five-minute flex, +network/battery/storage constraints, bounded exponential backoff, and a +replaceable immediate request. A headless provider container uses no widgets. +Foreground and headless mutations serialize by account; notification +reconciliation has its own process-wide gate and signals the foreground engine +after background changes. + +Android notification mappings are durable database rows. Reconciliation uses a +90-day/256-alarm horizon, stable collision-safe IDs, generation checks, exact +alarms when allowed and inexact fallback otherwise, quiet hours, private text, +Open/Snooze/Dismiss, due-today deduplication, and sync/conflict status channels. +Snooze/Dismiss persist and reconcile from the background callback without +requiring a live UI engine. Future scheduling is never recorded as proof that a +notification was displayed. + +ICS import previews content and destination before mutation; incoming shared +URIs are bounded `content:` reads. Single DAV items and native collections use +Storage Access Framework output. Settings persistence uses flush plus atomic +rename. Backup/transfer rules exclude the secure store, preferences, and local +database. The database itself is app-private but not described as encrypted. + +External console registration, production signing, Play app-signing identity, +physical-device lifecycle testing, live disposable-provider accounts, and store +publication cannot be completed from source alone. Their exact verification +state is maintained in [android_verification.md](android_verification.md). diff --git a/docs/android_provider_parity.md b/docs/android_provider_parity.md new file mode 100644 index 00000000..fd3bd847 --- /dev/null +++ b/docs/android_provider_parity.md @@ -0,0 +1,20 @@ +# Android provider parity + +Android uses the same repositories, conflict handling, DAV engines, pending +operation rules, and provider capability models as the desktop applications. +The Material editors hide or disable fields and operations not supported by the +selected concrete collection. + +| Provider | Authorization | Calendar | Tasks | Administration and limits | +|---|---|---|---|---| +| Google | Native Google Identity Services `AuthorizationClient`; explicit chooser; account-specific silent tokens | Read/write, recurrence, reminders, guests, colors and supported calendar mutation | Read/write Google Tasks fields; no invented reminder/time/recurrence support | Calendar/list creation and supported rename/remove/delete are queued through existing cloud mutations. Google alone is disabled when Play services is absent. | +| Microsoft | Native MSAL multiple-account public client; browser interactive flow; exact native-account silent lookup | Read/write Microsoft Graph calendars and supported recurrence/reminder/attendee fields | Read/write Microsoft To Do including supported due time, reminders, importance, categories, progress, recurrence, and hierarchy | Built-in/non-owner restrictions remain enforced. Signer-specific redirects must be registered. | +| Apple iCloud | CalDAV with user-supplied app-specific password in secure storage | Calendar-only read/write according to server privileges | Not supported | BusyMax does not imply Apple Reminders support or collection creation where the existing capability matrix denies it. | +| Nextcloud | Login Flow v2 and secure app-password storage | CalDAV read/write, native ICS, collection permissions, sharing/publishing/trash services | VTODO read/write with per-collection DAV capabilities | Calendar/task-list create, rename, delete/remove, reminders, and document-tree native export are available only when server privileges allow them. Local hosts use contextual Android 17 LAN permission. | +| WebCal | Confirmed subscription URL; embedded credentials remain in credential storage | Read-only subscription refresh and local rename/color metadata | Not supported | No event mutation or provider collection write is presented. | + +All five schedule views are available. Android defaults independently to +Agenda, while the existing desktop default remains Week. Creating from the +Schedule screen always asks for a destination when more than one writable +calendar or task list exists, preserving account/list identity even when remote +IDs collide. diff --git a/docs/android_release.md b/docs/android_release.md new file mode 100644 index 00000000..7dd33536 --- /dev/null +++ b/docs/android_release.md @@ -0,0 +1,62 @@ +# Android release and signing + +Production key material is never tracked. Copy +`android/key.properties.example` to ignored `android/key.properties`, point +`storeFile` at the owner-controlled keystore, and fill its passwords and alias +outside logs and shell history. Do not replace an established upload identity. + +With real public provider configuration and owner signing present, build both +formats with: + +```sh +tool/android/build_release.sh --all +tool/android/inspect_artifacts.sh +``` + +The individual release branches are `tool/android/build_release.sh --apk` and +`tool/android/build_release.sh --aab`. To install the already-built APK without +starting a debug session, use the Android SDK platform tool explicitly: + +```sh +adb -s DEVICE_ID install -r build/app/outputs/flutter-apk/app-release.apk +``` + +To launch/install from source with the explicit Android entrypoint, use +`tool/android/run.sh --device DEVICE_ID`. Certificate identity can be inspected +directly with the Build Tools 37 `apksigner verify --verbose --print-certs` +command, or as part of `tool/android/inspect_artifacts.sh`. The build and +inspection commands were exercised with test signing; device install/launch is +recorded as blocked, not passed, in the verification report. + +Outputs are: + +- `build/app/outputs/flutter-apk/app-release.apk` +- `build/app/outputs/bundle/release/app-release.aab` +- `build/android/android-artifact-inspection.txt` + +The script selects `lib/main_android.dart`, enables R8/resource shrinking, and +fails if Microsoft public registration or production signing is absent. For CI +or development only, `--test-signing --allow-unconfigured-providers` produces +an explicitly labeled debug-identity release-mode artifact. That artifact is +not a finished distribution build and its unavailable provider flows are +expected to remain disabled. + +`inspect_artifacts.sh` records SHA-256 values, APK certificate identity, +manifest metadata when `apkanalyzer` is available, ABIs, APK 16 KB zip +alignment, and ELF LOAD alignment in both APK and AAB. Set `BUNDLETOOL_JAR` to +an existing official bundletool jar to add its AAB configuration dump; the +script does not silently download tools. + +Three identities must be tracked separately: + +1. Debug/test-signed builds use the Android debug certificate. +2. A locally distributed release APK uses the certificate from + `android/key.properties`. +3. A Play-installed APK normally uses the Play app-signing certificate; the + AAB upload certificate may be different. + +Register Google and Microsoft for the certificate that signs the installed +APK. Before release, test a fresh install and upgrade on physical hardware, +then verify cold notification activation and a real calendar/task round trip. +Publishing, Play enrollment, store disclosures, and production key use require +the owner's authorization. diff --git a/docs/android_setup.md b/docs/android_setup.md new file mode 100644 index 00000000..8b8c6e3e --- /dev/null +++ b/docs/android_setup.md @@ -0,0 +1,99 @@ +# Android setup and provider registration + +BusyMax Android is a beta application with package ID +`io.busystack.busymax`, version `0.2.2+2`, minimum API 24, and compile/target +API 37. The supported toolchain is Flutter 3.47.2, its bundled Dart 3.13.2, +JDK 17, Android platform 37, and Build Tools 37.x. + +The resolved Android build uses Gradle 9.4.1 and Android Gradle Plugin 9.2.0. +Built-in Kotlin is enabled; the Kotlin plugin declaration is 2.4.0 so Flutter's +toolchain validation sees the supported language level. Flutter 3.47.2's own +Gradle integration still casts the Android application extension to the legacy +type, so this pinned project retains `android.newDsl=false`. Enabling the new +DSL was exercised and reproducibly fails in Flutter's Gradle plugin with an +`ApplicationExtensionImpl`/`AbstractAppExtension` class cast before the app is +configured. This is a narrow pinned-toolchain compatibility setting, not a +blanket AGP 9 restriction. + +Run the prerequisite check before changing the project: + +```sh +tool/android/check_prerequisites.sh +cp android/busymax.android.properties.example android/busymax.android.properties +``` + +`android/busymax.android.properties` is ignored. It contains public +registration values and public web links, never passwords or client secrets. +`android/local.properties` remains the normal ignored Android SDK pointer. + +The public configuration keys have these exact consumers: + +| Key | Consumer | +|---|---| +| `google.androidPackage` | Owner/CI registration check record; Google Identity Services validates the compiled application ID directly. | +| `google.debugSha256`, `google.releaseSha256`, `google.playSha256` | Owner/CI registration records for the three signer contexts; fingerprints are not credentials or embedded OAuth secrets. | +| `microsoft.clientId` | Gradle-generated MSAL string resource and `raw/busymax_msal_config.json`. | +| `microsoft.authorityTenant` | Audience tenant in the generated MSAL configuration; defaults to `common`. | +| `microsoft.signatureHash` | URL-encoded signer component of the generated `msauth` redirect. | +| `busymax.privacyPolicyUrl`, `busymax.supportUrl`, `busymax.homepageUrl` | `tool/android/build_release.sh` maps these to the corresponding `BUSYMAX_*` Dart defines consumed by `BuildConfig`. | + +## Google Identity Services + +Create Android OAuth registrations for `io.busystack.busymax`, enable the +Google Calendar and Google Tasks APIs, and register every certificate context +that will install the app: debug, locally distributed release, and Play app +signing. Put their SHA-256 fingerprints in the local configuration as an owner +record; Gradle does not send these values anywhere. Google authorization uses +the on-device `AuthorizationClient` with explicit account selection. The +desktop client ID and desktop client secret are neither required nor embedded +on Android. An AOSP device without Google Play services intentionally leaves +only Google unavailable; Microsoft, Apple, Nextcloud, and WebCal remain usable. + +## Microsoft MSAL + +Register a public/native Android application for personal and organizational +accounts as required by the product tenant policy. Configure: + +- `microsoft.clientId`: the real public client ID; +- `microsoft.authorityTenant`: normally `common`, or the owner-approved tenant; +- `microsoft.signatureHash`: the Base64 certificate signature hash used by + MSAL's signer-specific Android redirect. + +The generated redirect is +`msauth://io.busystack.busymax/`. The generated MSAL +resource uses browser authorization and `MULTIPLE` account mode. Debug, local +release, and Play-installed certificates need their own registered redirect. +BusyMax binds each domain account to an MSAL native account ID and uses that +exact ID for silent foreground and WorkManager token requests. + +## Other providers and Android permissions + +Apple iCloud Calendar continues to require an app-specific password. Nextcloud +uses Login Flow v2 and stores the returned app password in secure storage. +WebCal is read-only. On Android 17, a local Nextcloud hostname causes a +contextual local-network permission request; a public HTTPS server does not. +Notification and exact-alarm permissions are requested only from the relevant +Settings controls. Exact access improves reminder precision but is not required +for synchronization or for inexact reminders. + +Run on one explicitly selected device: + +```sh +tool/android/run.sh --device DEVICE_ID +``` + +The same entrypoint is available as the checked-in **BusyMax Android** IDE run +configuration. Live-provider tests must use disposable QA accounts and the +certificate registration matching the installed build. + +On a fresh checkout, the complete non-device verification command is: + +```sh +export JAVA_HOME=/path/to/jdk-17 +export ANDROID_SDK_ROOT=/path/to/android-sdk +tool/android/verify.sh +``` + +It enforces the lockfile, regenerates localization and Drift output, checks +generated consistency and formatting, runs analysis, platform boundaries, the +complete Flutter suite, native plugin unit tests, and Android lint. diff --git a/docs/android_verification.md b/docs/android_verification.md new file mode 100644 index 00000000..8df9eace --- /dev/null +++ b/docs/android_verification.md @@ -0,0 +1,162 @@ +# Android verification report + +This report records what was actually exercised for the Android implementation +on September 14, 2026. `PASSED` means the named command or procedure completed +successfully, `FAILED` means it was exercised and failed, `BLOCKED` means a +specific external prerequisite was unavailable, and `NOT RUN` means an +appropriate runner or environment was not available. A skipped live-provider +test is never counted as a pass. + +## Build identity and environment + +| Item | Recorded value | +|---|---| +| Source baseline | `9350af42e299f9da85ea2d046792a76e3a562cba` | +| Verification host | `skynet3`, Linux x86_64, Ubuntu kernel `7.0.0-31-generic` | +| Verification time | `2026-09-14T13:40:36-07:00` (`America/Vancouver`) | +| Flutter / Dart | Flutter `3.47.2` (`d3b14c8769`), bundled Dart `3.13.2` | +| Java | Eclipse Temurin JDK `17.0.18+8` | +| Android toolchain | platform `37.0` revision 2, Build Tools `37.0.0`, Gradle `9.4.1`, AGP `9.2.0` | +| Kotlin configuration | built-in Kotlin enabled; plugin declaration `2.4.0`; `android.newDsl=false` for the documented Flutter 3.47.2 compatibility requirement | +| Application | `io.busystack.busymax`, beta `0.2.2+2`, min SDK 24, target/compile SDK 37 | +| Available Flutter devices | Linux desktop and Chrome only; no Android emulator or physical Android device | + +## Automated and build evidence + +| Status | Check | Command or procedure | Evidence | +|---|---|---|---| +| PASSED | Pinned SDK prerequisites | `tool/android/check_prerequisites.sh` with Temurin JDK 17 | Flutter 3.47.2, Dart 3.13.2, API 37 and Build Tools 37.x accepted | +| PASSED | Localization and Drift generation | `flutter gen-l10n`; bundled `dart run build_runner build --force-jit` | Generated localization and schema sources are current; the existing Drift duplicate-reference warning remains non-fatal | +| PASSED | Formatting | bundled `dart format --output=none --set-exit-if-changed .` | 710 files unchanged by the check | +| PASSED | Static analysis | `flutter analyze` | `No issues found!` | +| PASSED | Platform boundary enforcement | bundled `dart run tool/check_platform_boundaries.dart` | Android foreground/headless graphs contain no desktop imports; malicious-fixture regression also passed | +| PASSED | Unit and widget suite | `flutter test --reporter compact` | 2,204 runnable tests passed; 10 live/environment tests skipped and are not reported as passes; [flutter-test.log](../build/android/flutter-test.log) | +| PASSED | Android adaptive widget matrix | `flutter test test/platform/android/android_adaptive_app_test.dart` | Schedule, Tasks and Settings exercised at 360x800, 412x915, 800x1280, landscape 915x412 and split 320x500, text scales 1.0/1.3/2.0, light/dark and RTL; no overflow; compact/wide navigation, labels and 48 dp controls checked | +| PASSED | Android notification policy | `flutter test test/platform/android/android_notification_policy_test.dart` | Stable IDs, collision handling, quiet-hours behavior and generation-bearing action payloads | +| PASSED | Android platform channel contract | `flutter test test/platform/android/android_platform_channel_test.dart` | Provider availability, account-specific silent Microsoft request and content-URI behavior | +| PASSED | Android settings persistence | `flutter test test/platform/android/android_settings_persistence_test.dart` | Android view preference is independent and atomically persisted | +| PASSED | Native plugin unit tests | `android/gradlew --project-dir android :busymax_android_platform:testDebugUnitTest` | JUnit report under `build/busymax_android_platform/reports/tests/testDebugUnitTest/`; unknown methods return `notImplemented` | +| PASSED | Android lint | `android/gradlew --project-dir android :app:lintDebug` | [lint-results-debug.html](../build/app/reports/lint-results-debug.html) | +| PASSED | Release-mode shrinking and packaging | `tool/android/build_release.sh --all --test-signing --allow-unconfigured-providers` | R8/resource shrinking completed; final APK and AAB paths are recorded below | +| PASSED | Release artifact inspection | `tool/android/inspect_artifacts.sh` | [android-artifact-inspection.txt](../build/android/android-artifact-inspection.txt) | +| BLOCKED | End-to-end clean-snapshot wrapper run | Export JDK 17 and `ANDROID_SDK_ROOT`, then run `tool/android/verify.sh` in a clean source snapshot | The clean snapshot passed prerequisite and lockfile resolution and entered deterministic generation, but this host terminated the duplicate heavy process with signal 9. Every component command is independently recorded as passed above in the real workspace; no clean-snapshot pass is claimed. | +| BLOCKED | Bundletool AAB configuration dump | `tool/android/inspect_artifacts.sh` | `BUNDLETOOL_JAR` was not installed. Supply a reviewed official bundletool jar and rerun. AAB ZIP contents and every packaged ELF were still inspected directly. | +| BLOCKED | Android integration test | `flutter test integration_test/android_platform_smoke_test.dart -d DEVICE_ID` | No Android device was attached. Supply an API 24/36/37 emulator or phone. | +| NOT RUN | Windows workflow | Existing Windows CI on an appropriate Windows runner | No Windows runner was available from this Linux workspace; no Windows result is claimed. | + +The existing Linux-compatible shared test suite passed from the supplied +baseline before Android changes and passed again after the implementation. +Android composition has an explicit `lib/main_android.dart`; the Linux and +Windows entrypoints remain separate. + +## Release artifact record + +The following artifacts are release-mode but deliberately signed with the +local Android debug identity. They are suitable for internal engineering +inspection only, not owner distribution or Play upload. + +| Artifact | Size | SHA-256 | +|---|---:|---| +| `build/app/outputs/flutter-apk/app-release.apk` | 84,506,244 bytes | `775fb6f3753b1e07b262453147030bad4dea988a8eb806b6738dbd12a91bff81` | +| `build/app/outputs/bundle/release/app-release.aab` | 80,651,652 bytes | `0a862e8a0d3bff9ec8e63a56145c2c4cb0b402d98d38761f00f2e6e2d37fcf49` | + +Inspection found `arm64-v8a`, `armeabi-v7a`, and `x86_64`, with Flutter, +application, Dart JNI, and SQLite native libraries in each ABI. Every packaged +ELF LOAD segment in the APK and AAB passed the 16 KB alignment check, and the +APK passed 16 KB ZIP alignment. This is binary inspection, not a substitute +for the blocked 16 KB system-image launch. + +The APK uses signature scheme v2. Its test signer is +`C=US, O=Android, CN=Android Debug`, SHA-256 +`7E:E9:78:47:B9:60:7F:3B:84:51:69:F4:C3:92:BA:EE:7F:8E:C2:C5:2C:AC:33:FE:32:7C:D1:A0:0E:94:9B:68`. +Manifest inspection confirmed package `io.busystack.busymax`, version name +`0.2.2`, version code `2`, minimum SDK 24 and target SDK 37. The merged release +permissions are Internet, network state, notifications, exact alarms, boot, +wake lock, vibration, foreground service/short-service, Android 17 local +network, and Android's generated non-exported dynamic-receiver permission. It +contains no contacts, location, device-calendar, SMS, NFC, broad storage, +package-enumeration, or full-screen-alarm permission. + +## Required functional matrix + +The automated rows below verify repository logic, persistence, presentation, +and platform contracts with deterministic fixtures. The corresponding real-OS +or live-service row remains blocked where this host lacks a device, disposable +provider account, or owner registration. + +| Status | Area | Result and evidence or exact unblock requirement | +|---|---|---| +| PASSED | Bootstrap and navigation — automated | Empty/populated/error/offline repository states, adaptive navigation, selected dates/filters, and cold/warm activation routing are covered by the full suite and Android widget/channel tests. | +| BLOCKED | Bootstrap and navigation — installed app | Fresh/returning install, process restoration, and cold/warm file/notification launches require an Android device and installed APK. | +| PASSED | Google — implementation/contract | Native `AuthorizationClient`, explicit selection, exact BusyMax/native binding, silent token path, removal isolation, Play-services availability, Calendar/Tasks repository reuse, and no Android desktop OAuth imports are implemented and contract-tested. | +| BLOCKED | Google — live | Two disposable Google accounts, enabled Calendar/Tasks APIs, and Android OAuth registrations for the debug, local-release and Play app-signing certificate fingerprints are required to exercise consent/cancel/deny/expiry/reconnect, real data, removal, and foreground/headless silent acquisition. | +| PASSED | Microsoft — implementation/contract | MSAL multiple-account browser flow, exact native-account binding, account-specific silent acquisition, interaction-required propagation and removal isolation are implemented and channel-tested. | +| BLOCKED | Microsoft — live | A real public client ID/tenant, signer-specific debug/local/Play redirects, disposable personal and organizational accounts, and an Android device are required for consent/browser return/silent worker/removal tests. | +| PASSED | Nextcloud — automated | Existing DAV engines, Login Flow v2 state, capabilities, pending operations, conflict behavior, collection administration and Android contextual-LAN result mapping pass shared/unit coverage; UI consumes concrete collection capabilities. | +| BLOCKED | Nextcloud — live | A disposable public-HTTPS server account plus an owner-configured LAN endpoint on an Android 17 device are required to verify polling/resume, app-password storage, real calendars/tasks/sharing/publishing/trash/admin and denied-LAN behavior. | +| PASSED | Apple and WebCal — automated | Calendar-only/read-only capability enforcement, credential separation, subscription refresh state and meaningful account failure state use the existing tested repositories and Android mobile UI. | +| BLOCKED | Apple and WebCal — live | Disposable iCloud credentials with an app-specific password and disposable WebCal endpoints are required to verify revocation, refresh, and private embedded credentials against real services. | +| PASSED | Calendar and tasks — automated | Agenda/day/week/month/year, all-day/timed/date-only items, recurrence/exceptions, due/undated/completed tasks, fields, hierarchy, read-only capabilities, source/account identity and empty/busy/error states are exercised by shared and Android widget tests. | +| BLOCKED | Calendar and tasks — service round trips | Named destructive workflows for Google, Microsoft, Nextcloud, Apple and WebCal require provider registrations and dedicated QA collections. No owner's real collection was modified. | +| PASSED | Offline and conflicts — automated | Cached browsing, supported queued mutations, replay/backoff, ETags, tombstones, preserved ICS properties, interrupted/ambiguous creates and serialized UI/worker account access are covered by the shared suite and account gate. | +| BLOCKED | Offline and conflicts — real network | Provider-side state after a dropped create response and reconnect must be observed using disposable remote collections and controlled networking on an Android device. | +| PASSED | Database and preferences — automated | Schema migration through version 18, foreground/background visibility, atomic settings writes, account removal, coordination release and account resurrection protections are covered by database, settings and sync tests. | +| BLOCKED | Database and preferences — OS interruption | Kill-during-write, upgrade, and account removal while a real WorkManager job owns the native process require an installed app and device instrumentation. | +| PASSED | Notifications — automated | Durable mapping, horizon/cap, exact/inexact policy, generation/stale-action rejection, edit/cancel/dedup, Open/Snooze/Dismiss persistence, quiet/private/summary logic and independent reconciliation gate pass deterministic tests. Future scheduling is not labeled delivery. | +| BLOCKED | Notifications — delivered behavior | A physical phone and signed installed build are required for screen-off timing, process death, permission/channel denial/revocation, reboot, package upgrade, battery idle, exact/inexact observation and cold/warm actions. | +| PASSED | Time and recurrence — automated | Shared timezone, DST gap/overlap, all-day/date-only/floating fields, recurrence and exception tests pass; Android system timezone/12–24-hour channel updates feed the app scope. | +| BLOCKED | Time and recurrence — system changes | A device is required to change clock/timezone/locale/12–24-hour settings and verify alarm rescheduling across provider/device timezone differences. | +| PASSED | Files and links — automated | ICS preview-before-import, no silent mutation, bounded and sanitized content-URI reads, SAF create/tree operations, malformed/oversized/cancel/duplicate activation handling, geo/browser fallback and missing-handler feedback are implemented and contract-tested. | +| BLOCKED | Files and links — document providers | A device with at least two document providers and maps/browser handlers is required for persisted/revoked grants, picker cancellation, native collection export, and cold-start share/open behavior. | +| PASSED | Security and release — source/artifact | Secret-free public examples, credential store separation, private notification payload design, backup exclusions, release manifest, R8, exact permissions, v2 test signature, and APK/AAB 16 KB binary inspection passed. | +| BLOCKED | Security and release — production | Owner keystore/CI secret configuration, Play enrollment/app-signing identity, provider console registration, fresh install/upgrade, 16 KB system-image launch, and production-signed inspection are required. | + +## Device, lifecycle, visual, and accessibility matrix + +| Status | Check | Evidence or unblock requirement | +|---|---|---| +| BLOCKED | API 24 device | No API 24 emulator/device was available. Start one with `adb` visibility and run `tool/android/run.sh --device DEVICE_ID` plus the integration and lifecycle procedures. | +| BLOCKED | API 36 device | No API 36 emulator/device was available. Required for the declared-range permission and lifecycle boundary. | +| BLOCKED | API 37 Google-services image | Required for Android 17 LAN permission and Google integration; no image/device was available. | +| BLOCKED | API 37 AOSP image | Required to verify Google-unavailable while Microsoft/DAV/WebCal remain usable; no image/device was available. | +| BLOCKED | 16 KB system image | Native binaries passed static inspection, but actual launch/runtime requires a configured 16 KB Android image. | +| BLOCKED | Physical phone | Authentication, signed-release reminder delivery, screen-off/battery-idle timing and cold notification activation require a physical phone. | +| BLOCKED | Rotation/split screen/background/foreground | Widget constraints passed, but the real Activity lifecycle needs an Android device. | +| BLOCKED | Ordinary process death/reboot | WorkManager, alarm restoration and database invalidation must be observed after real process removal and reboot. | +| BLOCKED | Force-stop | A device is required. Expected behavior is explicitly limited: Android does not guarantee work or alarms after user force-stop until the app is started again. | +| PASSED | Logical viewport rendering | The automated adaptive matrix covers all required logical sizes, themes, scales and RTL across each primary destination without overflow. | +| PASSED | Automated accessibility | Controls have semantic labels/tooltips, primary touch targets are at least 48 dp, large text is not clamped, and color is not the sole source/account/error indicator in Android widget tests. | +| BLOCKED | Actual Android screenshots | No Android device existed, so no device screenshots were produced. Linux screenshots are intentionally not presented as Android evidence. Required scenes are mixed schedule, selected-day month agenda, timed overlaps, tasks with undated/completed items, keyboard-visible event editor, account selection, settings, and loading/error/offline states using QA data. | +| BLOCKED | TalkBack/traversal/contrast | Requires a device or emulator with Android accessibility services and manual observation at all named screens. | +| BLOCKED | Attached keyboard/touch alternatives/predictive Back | Requires an Android Activity and input devices; verify draft preservation and route-first Back behavior. | +| BLOCKED | Profile/release performance | Requires a named phone with populated QA data. Record cold/warm startup, frame timings, account-switch time, list lengths and acceptance thresholds rather than inventing numbers. | + +## Owner/external completion checklist + +These items cannot be safely fabricated or performed without owner-controlled +inputs. They are deployment verification blockers, not unfinished source +implementation: + +1. Copy `android/busymax.android.properties.example` to the ignored local file, + enter the real Microsoft public client/authority/signature hash and public + product links, and register Google/MSAL for each actual signing context. +2. Provide owner-controlled production signing through ignored + `android/key.properties` or protected CI secrets. Rebuild without + `--test-signing`/`--allow-unconfigured-providers`, then record the new + fingerprints and artifact hashes. +3. Supply disposable provider accounts and dedicated QA collections; execute + every live-provider and destructive matrix row without touching production + data. +4. Run the API 24, 36, API 37 GMS/AOSP, 16 KB image and physical-phone matrices, + including process death, reboot, notification timing, installation and + upgrade. Capture the required real-screen evidence. +5. Run the preserved Windows workflow on a Windows runner, and use a reviewed + local `BUNDLETOOL_JAR` for the remaining AAB configuration inspection. +6. Complete Play Console signing, policy disclosures and publication only with + the owner's explicit authorization. Internal test readiness is not + authorization to publish. + +Reproducible configuration, run, build, install-oriented launch, signing and +inspection commands are documented in [android_setup.md](android_setup.md) and +[android_release.md](android_release.md). Provider behavior and limitations are +enumerated in [android_provider_parity.md](android_provider_parity.md). diff --git a/docs/privacy_data_map.md b/docs/privacy_data_map.md index 5447f1a2..3455b093 100644 --- a/docs/privacy_data_map.md +++ b/docs/privacy_data_map.md @@ -8,7 +8,7 @@ add telemetry or analytics. | Data | Local handling | Network or external destination | |---|---|---| -| Google/Microsoft OAuth tokens, Apple app-specific password, Nextcloud app password, WebCal subscription secret | Operating-system credential storage on unpackaged Linux and Windows; the strict Snap uses an encrypted Secret portal file | Used only with the selected provider, Nextcloud server, or WebCal endpoint | +| Google/Microsoft OAuth tokens, Apple app-specific password, Nextcloud app password, WebCal subscription secret | Operating-system credential storage on Android, unpackaged Linux, and Windows; the strict Snap uses an encrypted Secret portal file. Android Google/MSAL SDK caches are app-private. | Used only with the selected provider, Nextcloud server, or WebCal endpoint | | OAuth authorization code, PKCE verifier, and state | Short-lived in memory during browser sign-in; callbacks are restricted to loopback and secrets are redacted from logs | Selected provider's authorization/token endpoints | | Account, calendar, task-list, event, task, attendee, reminder, recurrence, DAV resource, synchronization, pending-operation, and conflict data | Cached and projected in the Drift database under the platform application-support directory | Selected provider during discovery, synchronization, or a user-requested mutation | | Local settings | Platform application-support/settings storage | No provider destination | @@ -54,7 +54,7 @@ viewed, saved, or synchronized. External opening occurs only after the user chooses **Show on map** or **Open link**. BusyMax hands the saved address or coordinates to a registered -`geo:`/maps handler on Linux, with a Google Maps browser fallback, or to the +`geo:`/maps handler on Android or Linux, with a browser fallback, or to the default browser on Windows. A complete saved HTTP(S) value opens its own host. The receiving application or website can receive the destination and normal network metadata. BusyMax does not retain a separate opening history or mutate @@ -76,6 +76,38 @@ Click, Open, Snooze, and Dismiss activation data returns through the packaged Windows activation bridge. Linux notifications use the desktop notification service to display reminder content and route actions. +## Android platform data and permissions + +Android calendar/task content and coordination metadata are stored in the +app-private database; credentials use Android-backed secure storage or the +app-private caches owned by Google Identity Services and MSAL. Android backup +and device-transfer rules exclude BusyMax preferences, secure-storage files, +and databases so provider credentials and cached private content are not copied +by the platform backup service. Cached calendar/task rows are not encrypted by +BusyMax and remain protected by the Android application sandbox. + +BusyMax requests internet and network-state access for provider sync. Android +13+ notification permission is requested only when a notification feature is +enabled. Exact-alarm access is optional: if unavailable, BusyMax clearly uses +inexact Android scheduling. Android 17 local-network permission is requested +contextually only when the user enters a local Nextcloud host; denial is kept +distinct from an authentication failure. No broad media or storage permission, +contacts permission, device-calendar permission, location permission, +advertising ID, analytics, Firebase, microphone, or camera access is used. + +ICS input and output use the Storage Access Framework. BusyMax consumes bounded +`content:` URI data selected or shared by the user and does not turn a content +URI into a filesystem path. Collection export writes only to the document tree +the user selected. Notification payloads contain bounded local identifiers and +generation data, not event bodies, account addresses, tokens, or credentials. +The notification body can be made private in Settings. + +WorkManager may start a headless Flutter engine to synchronize configured +accounts and refill notification schedules. Google and Microsoft access tokens +are acquired silently for the exact stored native account binding; short-lived +access tokens are not persisted by BusyMax. Periodic work and alarms remain +subject to Android battery, permission, idle, and explicit force-stop policy. + ## Feedback Every explicit feedback submission contains: diff --git a/integration_test/android_platform_smoke_test.dart b/integration_test/android_platform_smoke_test.dart new file mode 100644 index 00000000..f01347ce --- /dev/null +++ b/integration_test/android_platform_smoke_test.dart @@ -0,0 +1,16 @@ +import 'package:busymax_android_platform/busymax_android_platform.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + testWidgets('Android native bridge exposes device settings', (tester) async { + final platform = BusyMaxAndroidPlatform.instance; + + expect(await platform.currentTimeZoneId(), isNotEmpty); + expect(await platform.uses24HourFormat(), isA()); + expect(await platform.googleAuthorizationAvailable(), isA()); + expect(await platform.microsoftAuthorizationAvailable(), isA()); + }); +} diff --git a/lib/main_android.dart b/lib/main_android.dart new file mode 100644 index 00000000..e2a8ad23 --- /dev/null +++ b/lib/main_android.dart @@ -0,0 +1,26 @@ +import 'dart:async'; + +import 'package:flutter/widgets.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:logging/logging.dart'; + +import 'src/android/android_app.dart'; +import 'src/android/android_background.dart'; + +Future main(List arguments) async { + WidgetsFlutterBinding.ensureInitialized(); + Logger.root.level = Level.INFO; + Logger.root.onRecord.listen((record) { + // Provider tokens and credentials are never included in these messages. + debugPrint('${record.level.name}: ${record.loggerName}: ${record.message}'); + }); + final runtime = await AndroidHeadlessRuntime.create(); + await configureBusyMaxWorkmanager(); + unawaited(runtime.notifications.reconcile()); + runApp( + UncontrolledProviderScope( + container: runtime.container, + child: const AndroidBusyMaxApp(), + ), + ); +} diff --git a/lib/src/android/android_account_gate.dart b/lib/src/android/android_account_gate.dart new file mode 100644 index 00000000..0f91b824 --- /dev/null +++ b/lib/src/android/android_account_gate.dart @@ -0,0 +1,19 @@ +import 'package:busymax_android_platform/busymax_android_platform.dart'; + +import '../features/sync/account_sync_operations.dart'; + +final class AndroidCrossEngineAccountGate implements CrossEngineAccountGate { + const AndroidCrossEngineAccountGate(this._platform); + + final BusyMaxAndroidPlatform _platform; + + @override + Future run(String accountId, Future Function() operation) async { + final lease = await _platform.acquireAccountGate(accountId); + try { + return await operation(); + } finally { + await _platform.releaseAccountGate(lease); + } + } +} diff --git a/lib/src/android/android_app.dart b/lib/src/android/android_app.dart new file mode 100644 index 00000000..f63c0c20 --- /dev/null +++ b/lib/src/android/android_app.dart @@ -0,0 +1,292 @@ +import 'dart:async'; + +import 'package:busymax_android_platform/busymax_android_platform.dart'; +import 'package:drift/drift.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../l10n/generated/app_localizations.dart'; +import '../app/app_bootstrap.dart'; +import '../l10n/app_locale.dart'; +import '../l10n/locale_resolution.dart'; +import '../l10n/l10n.dart'; +import '../l10n/time_format_scope.dart'; +import 'android_notifications.dart'; +import 'presentation/android_schedule_screen.dart'; +import 'presentation/android_settings_screen.dart'; +import 'presentation/android_tasks_screen.dart'; + +final androidSelectedDestinationProvider = StateProvider((ref) => 0); + +class AndroidBusyMaxApp extends ConsumerWidget { + const AndroidBusyMaxApp({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final settings = ref.watch(appSettingsControllerProvider); + return MaterialApp( + debugShowCheckedModeBanner: false, + onGenerateTitle: (_) => 'BusyMax', + locale: settings.locale, + supportedLocales: busyMaxSupportedLocales, + localizationsDelegates: const [ + AppLocalizations.delegate, + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + ], + localeListResolutionCallback: (requested, supported) => + resolveBusyMaxLocales(requested, supported), + themeMode: settings.themeMode, + theme: _theme(Brightness.light), + darkTheme: _theme(Brightness.dark), + builder: (context, child) => BusyMaxTimeFormatScope( + formatter: BusyMaxTimeFormatter( + locale: Localizations.localeOf(context).toLanguageTag(), + use24Hour: resolveBusyMax24HourClock( + settings.timeFormatPreference, + systemUses24Hour: ref.watch(androidSystemUses24HourProvider), + ), + ), + child: child ?? const SizedBox.shrink(), + ), + home: const AndroidHomeShell(), + ); + } + + ThemeData _theme(Brightness brightness) { + final scheme = ColorScheme.fromSeed( + seedColor: const Color(0xff6d28d9), + brightness: brightness, + ); + return ThemeData( + useMaterial3: true, + colorScheme: scheme, + visualDensity: VisualDensity.standard, + scaffoldBackgroundColor: scheme.surface, + inputDecorationTheme: const InputDecorationTheme( + border: OutlineInputBorder(), + ), + cardTheme: const CardThemeData(margin: EdgeInsets.zero), + ); + } +} + +class AndroidHomeShell extends ConsumerStatefulWidget { + const AndroidHomeShell({super.key}); + + @override + ConsumerState createState() => _AndroidHomeShellState(); +} + +class _AndroidHomeShellState extends ConsumerState + with WidgetsBindingObserver { + StreamSubscription? _platformEvents; + StreamSubscription? _reminderActivations; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + _platformEvents = BusyMaxAndroidPlatform.instance.events.listen( + (event) => unawaited(_handlePlatformEvent(event)), + ); + _reminderActivations = ref + .read(androidNotificationServiceProvider) + .activations + .listen((activation) => unawaited(_openReminder(activation))); + WidgetsBinding.instance.addPostFrameCallback((_) { + unawaited(_consumeInitialActivations()); + }); + unawaited(_refresh()); + } + + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + unawaited(_platformEvents?.cancel()); + unawaited(_reminderActivations?.cancel()); + super.dispose(); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (state == AppLifecycleState.resumed) unawaited(_refresh()); + } + + Future _refresh() async { + try { + await ref.read(allAccountsSyncRunnerProvider)(); + } on Object { + // SyncRuns and account state expose the individual failure. + } + await ref.read(notificationReconcilerProvider).reconcile(); + } + + Future _consumeInitialActivations() async { + final document = await BusyMaxAndroidPlatform.instance + .takeInitialActivation(); + if (document != null) await _openDocumentActivation(document); + if (!mounted) return; + final reminder = ref + .read(androidNotificationServiceProvider) + .takeInitialActivation(); + if (reminder != null) await _openReminder(reminder); + } + + Future _handlePlatformEvent(AndroidPlatformEvent event) async { + if (event.type == 'systemSettingsChanged') { + final zone = await BusyMaxAndroidPlatform.instance.currentTimeZoneId(); + final uses24Hour = await BusyMaxAndroidPlatform.instance + .uses24HourFormat(); + ref.read(androidLocalTimeZoneStateProvider.notifier).state = zone; + ref.read(androidSystemUses24HourProvider.notifier).state = uses24Hour; + await ref + .read(androidNotificationServiceProvider) + .initialize(timeZoneId: zone); + await ref.read(notificationReconcilerProvider).reconcile(); + return; + } + if (event.type == 'dataChanged') { + ref.invalidate(accountsStreamProvider); + ref.invalidate(accountManagementStreamProvider); + ref.invalidate(calendarSourcesStreamProvider); + ref.invalidate(scheduleTaskListsProvider); + ref.invalidate(scheduleDataRevisionProvider); + ref.invalidate(davConflictsStreamProvider); + ref.invalidate(webCalSubscriptionsProvider); + return; + } + if (event.type == 'document') { + await _openDocumentActivation(AndroidActivation.fromMap(event.data)); + } + } + + Future _openDocumentActivation(AndroidActivation activation) async { + final uri = activation.uri; + if (!mounted || activation.kind != 'document' || uri == null) return; + try { + final document = await BusyMaxAndroidPlatform.instance.readDocumentUri( + uri, + ); + if (!mounted) return; + await showAndroidIcsImport(context, ref, document: document); + } on Object catch (error) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.importIcsFailed('$error'))), + ); + } + } + } + + Future _openReminder(AndroidReminderActivation activation) async { + if (activation.sourceType == 'summary') { + ref.read(androidSelectedDestinationProvider.notifier).state = 1; + return; + } + final database = ref.read(databaseProvider); + final row = + await (database.select(database.notificationSchedule)..where( + (schedule) => + schedule.id.equals(activation.scheduleId) & + schedule.generation.equals(activation.generation) & + schedule.accountId.equals(activation.accountId) & + schedule.sourceId.equals(activation.itemId), + )) + .getSingleOrNull(); + if (!mounted) return; + if (row == null) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(context.l10n.noEventsOrTasks))); + return; + } + ref.read(androidSelectedDestinationProvider.notifier).state = 0; + if (activation.sourceType == 'event') { + await showAndroidEventEditor(context, ref, eventId: activation.itemId); + } else if (activation.sourceType == 'task') { + await showAndroidTaskById( + context, + ref, + accountId: activation.accountId, + taskId: activation.itemId, + ); + } + } + + @override + Widget build(BuildContext context) { + final selected = ref.watch(androidSelectedDestinationProvider); + final destinations = [ + NavigationDestination( + icon: const Icon(Icons.calendar_today_outlined), + selectedIcon: const Icon(Icons.calendar_today), + label: context.l10n.scheduleSection, + ), + NavigationDestination( + icon: const Icon(Icons.checklist_outlined), + selectedIcon: const Icon(Icons.checklist), + label: context.l10n.tasks, + ), + NavigationDestination( + icon: const Icon(Icons.settings_outlined), + selectedIcon: const Icon(Icons.settings), + label: context.l10n.settings, + ), + ]; + final pages = const [ + AndroidScheduleScreen(), + AndroidTasksScreen(), + AndroidSettingsScreen(), + ]; + return LayoutBuilder( + builder: (context, constraints) { + final useRail = constraints.maxWidth >= 840; + final content = IndexedStack(index: selected, children: pages); + if (useRail) { + return Scaffold( + body: SafeArea( + child: Row( + children: [ + NavigationRail( + selectedIndex: selected, + extended: constraints.maxWidth >= 1200, + onDestinationSelected: (value) => + ref + .read( + androidSelectedDestinationProvider.notifier, + ) + .state = + value, + destinations: [ + for (final destination in destinations) + NavigationRailDestination( + icon: destination.icon, + selectedIcon: destination.selectedIcon, + label: Text(destination.label), + ), + ], + ), + const VerticalDivider(width: 1), + Expanded(child: content), + ], + ), + ), + ); + } + return Scaffold( + body: SafeArea(child: content), + bottomNavigationBar: NavigationBar( + selectedIndex: selected, + onDestinationSelected: (value) => + ref.read(androidSelectedDestinationProvider.notifier).state = + value, + destinations: destinations, + ), + ); + }, + ); + } +} diff --git a/lib/src/android/android_authorization.dart b/lib/src/android/android_authorization.dart new file mode 100644 index 00000000..c6b5141d --- /dev/null +++ b/lib/src/android/android_authorization.dart @@ -0,0 +1,281 @@ +import 'dart:convert'; + +import 'package:busymax_android_platform/busymax_android_platform.dart'; +import 'package:flutter/services.dart'; +import 'package:http/http.dart' as http; + +import '../config/build_config.dart'; +import '../core/auth/account_token_broker.dart'; +import '../core/auth/oauth_models.dart'; +import '../core/secrets/secret_store.dart'; +import '../google_tasks/api/google_tasks_api_surface.dart'; +import '../google_tasks/oauth/oauth_service.dart'; +import '../microsoft_todo/api/microsoft_todo_api_models.dart'; +import '../microsoft_todo/oauth/microsoft_oauth_service.dart'; +import '../providers/busy_provider.dart'; + +const _googleScopes = [ + 'openid', + 'email', + 'profile', + googleTasksReadWriteScope, + googleCalendarReadWriteScope, +]; + +const _microsoftNativeScopes = [ + 'User.Read', + 'Tasks.ReadWrite', + 'Calendars.ReadWrite', +]; + +const _microsoftStoredScopes = { + 'https://graph.microsoft.com/User.Read', + 'https://graph.microsoft.com/Tasks.ReadWrite', + 'https://graph.microsoft.com/Calendars.ReadWrite', +}; + +final class AndroidAuthorizationBroker + implements OAuthGateway, MicrosoftOAuthGateway, AccountTokenBroker { + AndroidAuthorizationBroker({ + required BusyMaxAndroidPlatform platform, + required http.Client httpClient, + required SecretStore secretStore, + required BuildConfig config, + }) : _platform = platform, + _httpClient = httpClient, + _secretStore = secretStore, + _config = config; + + final BusyMaxAndroidPlatform _platform; + final http.Client _httpClient; + final SecretStore _secretStore; + final BuildConfig _config; + final Map _lastAccessTokens = {}; + + @override + Future get activeAccountId => _secretStore.readActiveAccountId(); + + @override + Future readActiveTokenSet() async { + final accountId = await activeAccountId; + if (accountId == null || !accountId.startsWith('google:')) return null; + return _googleToken(accountId); + } + + @override + Future signIn({String? loginHint}) async { + try { + final native = await _platform.authorizeGoogleInteractively( + scopes: _googleScopes, + ); + final tokenSet = _tokenSet(native, extraScopes: _googleScopes); + final user = await fetchUserInfo(tokenSet); + final subject = user?.subject?.trim(); + if (subject == null || subject.isEmpty) { + await _platform.clearRejectedToken(native.accessToken); + throw const OAuthException( + 'OAuthMissingSubject', + 'Google did not return an account identity.', + ); + } + final accountId = 'google:$subject'; + await _platform.bindAuthorization( + provider: BusyProvider.google.storageValue, + accountId: accountId, + nativeAccountId: native.nativeAccountId, + username: native.username ?? user?.email, + ); + await _secretStore.setActiveAccountId(accountId); + _lastAccessTokens[accountId] = native.accessToken; + return OAuthSignInResult(accountId: accountId, tokenSet: tokenSet); + } on PlatformException catch (error) { + throw _oauthError(error, provider: 'Google'); + } + } + + @override + Future signInWithMicrosoft() async { + try { + final native = await _platform.authorizeMicrosoftInteractively( + scopes: _microsoftNativeScopes, + ); + final tokenSet = _tokenSet(native, extraScopes: _microsoftStoredScopes); + final user = await _microsoftMe(native.accessToken); + if (user.id.trim().isEmpty) { + throw const OAuthException( + 'MicrosoftOAuthMissingUserId', + 'Microsoft Graph did not return a user id.', + ); + } + final accountId = 'microsoft:${user.id}'; + await _platform.bindAuthorization( + provider: BusyProvider.microsoft.storageValue, + accountId: accountId, + nativeAccountId: native.nativeAccountId, + username: native.username ?? user.mail ?? user.userPrincipalName, + authority: native.authority, + ); + await _secretStore.setActiveAccountId(accountId); + _lastAccessTokens[accountId] = native.accessToken; + return MicrosoftOAuthSignInResult( + accountId: accountId, + tokenSet: tokenSet, + user: user, + ); + } on PlatformException catch (error) { + throw _oauthError(error, provider: 'Microsoft'); + } + } + + @override + Future fetchUserInfo(OAuthTokenSet tokenSet) async { + final response = await _httpClient.get( + Uri.https('openidconnect.googleapis.com', '/v1/userinfo'), + headers: {'Authorization': 'Bearer ${tokenSet.accessToken}'}, + ); + if (response.statusCode < 200 || response.statusCode >= 300) return null; + final decoded = jsonDecode(response.body); + return decoded is Map + ? GoogleUserInfo.fromJson(decoded.cast()) + : null; + } + + Future _microsoftMe(String accessToken) async { + final response = await _httpClient.get( + Uri.parse('${_config.microsoftGraphBaseUrl}/me'), + headers: {'Authorization': 'Bearer $accessToken'}, + ); + if (response.statusCode < 200 || response.statusCode >= 300) { + throw OAuthException( + 'MicrosoftOAuthUserInfoFailed', + 'Microsoft account details could not be loaded (HTTP ${response.statusCode}).', + ); + } + final decoded = jsonDecode(response.body); + if (decoded is! Map) { + throw const OAuthException( + 'MicrosoftOAuthUserInfoFailed', + 'Microsoft returned an invalid account response.', + ); + } + return MicrosoftTodoUserDto.fromJson(decoded.cast()); + } + + @override + Future authorizationHeader( + BusyProvider provider, + String accountId, + ) async { + final token = switch (provider) { + BusyProvider.google => await _platform.authorizeGoogleSilently( + accountId: accountId, + scopes: _googleScopes, + ), + BusyProvider.microsoft => await _platform.authorizeMicrosoftSilently( + accountId: accountId, + scopes: _microsoftNativeScopes, + ), + _ => throw StateError('$provider does not use native authorization.'), + }; + _lastAccessTokens[accountId] = token.accessToken; + return 'Bearer ${token.accessToken}'; + } + + @override + Future recoverUnauthorized( + BusyProvider provider, + String accountId, + ) async { + final rejected = _lastAccessTokens.remove(accountId); + if (provider == BusyProvider.google && rejected != null) { + await _platform.clearRejectedToken(rejected); + } + await authorizationHeader(provider, accountId); + } + + Future _googleToken(String accountId) async { + final native = await _platform.authorizeGoogleSilently( + accountId: accountId, + scopes: _googleScopes, + ); + _lastAccessTokens[accountId] = native.accessToken; + return _tokenSet(native, extraScopes: _googleScopes); + } + + @override + Future refreshActiveToken() async { + final accountId = await activeAccountId; + if (accountId == null) { + throw const OAuthException( + 'OAuthMissingToken', + 'No Google account is active.', + ); + } + await recoverUnauthorized(BusyProvider.google, accountId); + return _googleToken(accountId); + } + + @override + Future revokeAndSignOutAccount(String accountId) async { + await _platform.removeAuthorization( + provider: BusyProvider.google.storageValue, + accountId: accountId, + revoke: true, + ); + await clearLocalSession(accountId: accountId); + } + + @override + Future revokeAuthorization(String accountId) => + _platform.removeAuthorization( + provider: BusyProvider.google.storageValue, + accountId: accountId, + revoke: true, + ); + + @override + Future clearLocalSession({String? accountId}) async { + final target = accountId ?? await activeAccountId; + if (target != null) _lastAccessTokens.remove(target); + if (target == null || await activeAccountId == target) { + await _secretStore.clearActiveAccount(); + } + } + + @override + Future signOutAccount(String accountId) async { + await _platform.removeAuthorization( + provider: BusyProvider.microsoft.storageValue, + accountId: accountId, + ); + await clearLocalSession(accountId: accountId); + } + + @override + Future cancelSignIn() => _platform.cancelInteractiveAuthorization(); + + OAuthTokenSet _tokenSet( + AndroidAuthorizationToken token, { + Iterable extraScopes = const [], + }) => OAuthTokenSet( + accessToken: token.accessToken, + expiresAtUtc: + token.expiresAtUtc ?? + DateTime.now().toUtc().add(const Duration(minutes: 45)), + tokenType: 'Bearer', + scopes: {...token.scopes, ...extraScopes}, + ); +} + +OAuthException _oauthError( + PlatformException error, { + required String provider, +}) { + final cancelled = error.code == 'android/auth-cancelled'; + return OAuthException( + cancelled ? 'OAuthSignInCancelled' : error.code, + cancelled + ? '$provider sign-in was cancelled.' + : (error.message ?? '$provider authorization failed.'), + ); +} diff --git a/lib/src/android/android_background.dart b/lib/src/android/android_background.dart new file mode 100644 index 00000000..ad8ac720 --- /dev/null +++ b/lib/src/android/android_background.dart @@ -0,0 +1,178 @@ +import 'dart:async'; +import 'dart:ui'; + +import 'package:busymax_android_platform/busymax_android_platform.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:http/http.dart' as http; +import 'package:workmanager/workmanager.dart'; + +import '../app/app_bootstrap.dart'; +import '../config/build_config.dart'; +import '../core/secrets/secret_store.dart'; +import '../db/app_database.dart'; +import '../l10n/app_locale.dart'; +import '../../l10n/generated/app_localizations.dart'; +import 'android_account_gate.dart'; +import 'android_authorization.dart'; +import 'android_notifications.dart'; +import 'android_time_zone.dart'; + +const busyMaxPeriodicSyncUniqueName = 'busymax.periodic-sync.v1'; +const busyMaxPeriodicSyncTaskName = 'busymax.sync-and-reconcile'; +const busyMaxImmediateSyncUniqueName = 'busymax.immediate-sync.v1'; + +@pragma('vm:entry-point') +void busyMaxWorkmanagerDispatcher() { + Workmanager().executeTask((taskName, inputData) async { + if (taskName != busyMaxPeriodicSyncTaskName) return true; + WidgetsFlutterBinding.ensureInitialized(); + DartPluginRegistrant.ensureInitialized(); + final runtime = await AndroidHeadlessRuntime.create(); + try { + await runtime.container.read(allAccountsSyncRunnerProvider)(); + await runtime.notifications.reconcile(); + await BusyMaxAndroidPlatform.instance.notifyDataChanged(); + return true; + } on Object { + // WorkManager applies bounded exponential backoff. Provider-specific + // failures are already persisted in SyncRuns by the shared engines. + return false; + } finally { + await runtime.dispose(); + } + }); +} + +Future configureBusyMaxWorkmanager() async { + final workmanager = Workmanager(); + await workmanager.initialize(busyMaxWorkmanagerDispatcher); + await workmanager.registerPeriodicTask( + busyMaxPeriodicSyncUniqueName, + busyMaxPeriodicSyncTaskName, + frequency: const Duration(minutes: 15), + flexInterval: const Duration(minutes: 5), + constraints: Constraints( + networkType: NetworkType.connected, + requiresBatteryNotLow: true, + requiresStorageNotLow: true, + ), + existingWorkPolicy: ExistingPeriodicWorkPolicy.update, + backoffPolicy: BackoffPolicy.exponential, + backoffPolicyDelay: const Duration(minutes: 10), + tag: 'busymax-sync', + ); +} + +Future enqueueImmediateBusyMaxSync() => Workmanager().registerOneOffTask( + busyMaxImmediateSyncUniqueName, + busyMaxPeriodicSyncTaskName, + constraints: Constraints(networkType: NetworkType.connected), + existingWorkPolicy: ExistingWorkPolicy.replace, + backoffPolicy: BackoffPolicy.exponential, + backoffPolicyDelay: const Duration(minutes: 10), + tag: 'busymax-sync', +); + +final class AndroidHeadlessRuntime { + AndroidHeadlessRuntime._({ + required this.container, + required this.database, + required this.notifications, + required http.Client httpClient, + }) : _httpClient = httpClient; + + static Future create() async { + final platform = BusyMaxAndroidPlatform.instance; + const storage = FlutterSecureStorage( + aOptions: AndroidOptions(resetOnError: true), + ); + final settings = await loadInitialAppSettings( + const JsonFileLocalSettingsStore(), + ); + final timeZoneId = await platform.currentTimeZoneId(); + final uses24Hour = await platform.uses24HourFormat(); + final database = AppDatabase.open(); + final client = http.Client(); + final broker = AndroidAuthorizationBroker( + platform: platform, + httpClient: client, + secretStore: SecureSecretStore(storage), + config: BuildConfig.forAndroid(), + ); + late ProviderContainer container; + final notifications = AndroidNotificationService( + database: database, + settings: () => container.read(appSettingsControllerProvider), + platform: platform, + strings: () { + final current = container.read(appSettingsControllerProvider); + final locale = + current.locale ?? + resolveBusyMaxLocales( + PlatformDispatcher.instance.locales, + busyMaxSupportedLocales, + ); + final l10n = lookupAppLocalizations(locale); + return AndroidNotificationStrings( + channelName: l10n.reminderGroup, + channelDescription: '${l10n.eventReminders} · ${l10n.taskReminders}', + statusChannelName: l10n.sync, + statusChannelDescription: l10n.syncConflicts, + privateTitle: 'BusyMax', + open: l10n.notificationOpenAction, + snooze: l10n.notificationSnoozeAction, + dismiss: l10n.notificationDismissAction, + syncFailureTitle: l10n.syncFailureNotificationTitle, + syncFailureBody: l10n.davTemporarilyUnavailable, + conflictTitle: l10n.conflictNotificationTitle, + dueTodayTitle: l10n.dueTodayNotificationTitle, + dueTodayBody: l10n.dueTodayNotificationBody, + ); + }, + ); + await notifications.initialize(timeZoneId: timeZoneId); + container = ProviderContainer( + overrides: [ + buildConfigProvider.overrideWithValue(BuildConfig.forAndroid()), + databaseProvider.overrideWithValue(database), + secureStorageProvider.overrideWithValue(storage), + initialAppSettingsProvider.overrideWithValue(settings), + localTimeZoneSourceProvider.overrideWithValue( + AndroidLocalTimeZoneSource(platform), + ), + androidLocalTimeZoneStateProvider.overrideWith((ref) => timeZoneId), + androidSystemUses24HourProvider.overrideWith((ref) => uses24Hour), + applicationOAuthGatewayProvider.overrideWithValue(broker), + applicationMicrosoftOAuthServiceProvider.overrideWithValue(broker), + accountTokenBrokerProvider.overrideWithValue(broker), + crossEngineAccountGateProvider.overrideWithValue( + AndroidCrossEngineAccountGate(platform), + ), + notificationReconcilerProvider.overrideWithValue(notifications), + androidNotificationServiceProvider.overrideWithValue(notifications), + operationalNotificationReporterProvider.overrideWithValue( + notifications, + ), + ], + ); + return AndroidHeadlessRuntime._( + container: container, + database: database, + notifications: notifications, + httpClient: client, + ); + } + + final ProviderContainer container; + final AppDatabase database; + final AndroidNotificationService notifications; + final http.Client _httpClient; + + Future dispose() async { + container.dispose(); + _httpClient.close(); + await database.close(); + } +} diff --git a/lib/src/android/android_notifications.dart b/lib/src/android/android_notifications.dart new file mode 100644 index 00000000..9f253bef --- /dev/null +++ b/lib/src/android/android_notifications.dart @@ -0,0 +1,785 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:busymax_android_platform/busymax_android_platform.dart'; +import 'package:drift/drift.dart'; +import 'package:flutter_local_notifications/flutter_local_notifications.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:timezone/data/latest.dart' as tz_data; +import 'package:timezone/timezone.dart' as tz; + +import '../app/app_settings.dart'; +import '../core/time/stored_temporal_projection.dart'; +import '../db/app_database.dart'; +import '../features/accounts/data/accounts_repository.dart'; +import '../features/notifications/notification_reconciler.dart'; + +const androidReminderChannelId = 'busymax_reminders_v1'; +const androidStatusChannelId = 'busymax_status_v1'; +const androidNotificationActionOpen = 'busymax.open'; +const androidNotificationActionSnooze = 'busymax.snooze'; +const androidNotificationActionDismiss = 'busymax.dismiss'; +const _maximumScheduledAlarms = 256; +const _schedulingHorizon = Duration(days: 90); + +final androidNotificationServiceProvider = Provider( + (ref) => throw UnsupportedError( + 'AndroidNotificationService must be supplied by the Android runtime.', + ), +); + +@pragma('vm:entry-point') +Future busyMaxNotificationBackgroundResponse( + NotificationResponse response, +) async { + final payload = _AndroidReminderPayload.tryParse(response.payload); + if (payload == null || + response.actionId == null || + response.actionId == androidNotificationActionOpen) { + return; + } + final platform = BusyMaxAndroidPlatform.instance; + final lease = await platform.acquireAccountGate('__notifications__'); + final database = AppDatabase.open(); + var changed = false; + try { + final row = await (database.select( + database.notificationSchedule, + )..where((table) => table.id.equals(payload.scheduleId))).getSingleOrNull(); + if (row == null || row.generation != payload.generation) return; + final account = await (database.select( + database.accounts, + )..where((table) => table.id.equals(payload.accountId))).getSingleOrNull(); + if (account == null) return; + final itemExists = switch (payload.sourceType) { + 'event' => + await (database.select(database.calendarEvents)..where( + (table) => + table.accountId.equals(payload.accountId) & + table.id.equals(payload.sourceId) & + table.isDeleted.equals(false), + )) + .getSingleOrNull() != + null, + 'task' => + await (database.select(database.tasks)..where( + (table) => + table.accountId.equals(payload.accountId) & + table.id.equals(payload.sourceId) & + table.pendingDelete.equals(false), + )) + .getSingleOrNull() != + null, + _ => false, + }; + if (!itemExists) return; + final now = DateTime.now().toUtc().millisecondsSinceEpoch; + final update = switch (response.actionId) { + androidNotificationActionSnooze => NotificationScheduleCompanion( + generation: Value('${row.generation}:snooze:$now'), + snoozedUntilUtc: Value( + DateTime.now() + .toUtc() + .add(const Duration(minutes: 10)) + .millisecondsSinceEpoch, + ), + sentAtUtc: const Value(null), + updatedAtLocal: Value(now), + ), + androidNotificationActionDismiss => NotificationScheduleCompanion( + dismissedAtUtc: Value(now), + updatedAtLocal: Value(now), + ), + _ => null, + }; + if (update != null) { + await (database.update(database.notificationSchedule)..where( + (table) => + table.id.equals(payload.scheduleId) & + table.generation.equals(payload.generation), + )) + .write(update); + await (database.delete( + database.androidNotificationMappings, + )..where((table) => table.scheduleId.equals(payload.scheduleId))).go(); + changed = true; + } + } finally { + await database.close(); + await platform.releaseAccountGate(lease); + } + if (changed) { + final settings = await loadInitialAppSettings( + const JsonFileLocalSettingsStore(), + ); + final reconcileDatabase = AppDatabase.open(); + final service = AndroidNotificationService( + database: reconcileDatabase, + settings: () => settings, + platform: platform, + ); + try { + await service.initialize(timeZoneId: await platform.currentTimeZoneId()); + await service.reconcile(); + await platform.notifyDataChanged(); + } finally { + await reconcileDatabase.close(); + } + } +} + +final class AndroidReminderActivation { + const AndroidReminderActivation({ + required this.scheduleId, + required this.generation, + required this.sourceType, + required this.accountId, + required this.itemId, + }); + + final String scheduleId; + final String generation; + final String sourceType; + final String accountId; + final String itemId; +} + +final class AndroidNotificationStrings { + const AndroidNotificationStrings({ + this.channelName = 'Reminders', + this.channelDescription = 'Calendar event and task reminders', + this.statusChannelName = 'Synchronization', + this.statusChannelDescription = 'Synchronization status and conflicts', + this.privateTitle = 'BusyMax reminder', + this.open = 'Open', + this.snooze = 'Snooze', + this.dismiss = 'Dismiss', + this.syncFailureTitle = 'BusyMax sync issue', + this.syncFailureBody = 'Open BusyMax to review the account status.', + this.conflictTitle = 'BusyMax sync conflict', + this.dueTodayTitle = 'Tasks due today', + this.dueTodayBody = _defaultDueTodayBody, + }); + + final String channelName; + final String channelDescription; + final String statusChannelName; + final String statusChannelDescription; + final String privateTitle; + final String open; + final String snooze; + final String dismiss; + final String syncFailureTitle; + final String syncFailureBody; + final String conflictTitle; + final String dueTodayTitle; + final String Function(int count) dueTodayBody; +} + +final class AndroidNotificationService + implements NotificationReconciler, OperationalNotificationReporter { + AndroidNotificationService({ + required AppDatabase database, + required AppSettings Function() settings, + BusyMaxAndroidPlatform? platform, + AndroidNotificationStrings Function()? strings, + FlutterLocalNotificationsPlugin? plugin, + }) : _database = database, + _settings = settings, + _platform = platform ?? BusyMaxAndroidPlatform.instance, + _strings = strings ?? _defaultStrings, + _plugin = plugin ?? FlutterLocalNotificationsPlugin(); + + final AppDatabase _database; + final AppSettings Function() _settings; + final BusyMaxAndroidPlatform _platform; + final AndroidNotificationStrings Function() _strings; + final FlutterLocalNotificationsPlugin _plugin; + final StreamController _activations = + StreamController.broadcast(sync: true); + AndroidReminderActivation? _initialActivation; + bool _initialized = false; + bool _reconciling = false; + bool _reconcileAgain = false; + String _precisionDiagnostic = 'Alarm precision has not been checked.'; + + String get precisionDiagnostic => _precisionDiagnostic; + Stream get activations => _activations.stream; + + AndroidReminderActivation? takeInitialActivation() { + final value = _initialActivation; + _initialActivation = null; + return value; + } + + Future initialize({required String timeZoneId}) async { + if (_initialized) { + updateTimeZone(timeZoneId); + return; + } + tz_data.initializeTimeZones(); + updateTimeZone(timeZoneId); + await _plugin.initialize( + settings: const InitializationSettings( + android: AndroidInitializationSettings('ic_notification'), + ), + onDidReceiveNotificationResponse: _foregroundResponse, + onDidReceiveBackgroundNotificationResponse: + busyMaxNotificationBackgroundResponse, + ); + final launch = await _plugin.getNotificationAppLaunchDetails(); + if (launch?.didNotificationLaunchApp ?? false) { + _initialActivation = _publicActivation( + _AndroidReminderPayload.tryParse(launch?.notificationResponse?.payload), + ); + } + _initialized = true; + } + + void updateTimeZone(String timeZoneId) { + try { + tz.setLocalLocation(tz.getLocation(timeZoneId)); + } on ArgumentError { + tz.setLocalLocation(tz.UTC); + } + } + + Future requestNotificationPermission() async { + final android = _plugin + .resolvePlatformSpecificImplementation< + AndroidFlutterLocalNotificationsPlugin + >(); + return await android?.requestNotificationsPermission() ?? true; + } + + Future requestExactAlarmPermission() async { + final android = _plugin + .resolvePlatformSpecificImplementation< + AndroidFlutterLocalNotificationsPlugin + >(); + return await android?.requestExactAlarmsPermission() ?? false; + } + + Future canScheduleExactly() async { + final android = _plugin + .resolvePlatformSpecificImplementation< + AndroidFlutterLocalNotificationsPlugin + >(); + return await android?.canScheduleExactNotifications() ?? false; + } + + @override + Future notifySyncFailure(Object error) async { + final settings = _settings(); + if (!settings.notifySyncFailures || _isQuietNow(settings)) return; + final strings = _strings(); + await _showStatus( + id: 0x425901, + title: strings.syncFailureTitle, + body: strings.syncFailureBody, + ); + } + + @override + Future notifyConflict(String summary) async { + final settings = _settings(); + if (!settings.notifyConflicts || _isQuietNow(settings)) return; + final strings = _strings(); + await _showStatus( + id: 0x425902, + title: strings.conflictTitle, + body: settings.notificationDetailLevel == NotificationDetailLevel.private + ? null + : summary, + ); + } + + Future _showStatus({ + required int id, + required String title, + required String? body, + }) async { + if (!_initialized) return; + final strings = _strings(); + await _plugin.show( + id: id, + title: title, + body: body, + notificationDetails: NotificationDetails( + android: AndroidNotificationDetails( + androidStatusChannelId, + strings.statusChannelName, + channelDescription: strings.statusChannelDescription, + importance: Importance.defaultImportance, + priority: Priority.defaultPriority, + visibility: + _settings().notificationDetailLevel == + NotificationDetailLevel.private + ? NotificationVisibility.private + : NotificationVisibility.public, + ), + ), + ); + } + + @override + Future reconcile() async { + if (!_initialized) return; + if (_reconciling) { + _reconcileAgain = true; + return; + } + _reconciling = true; + String? lease; + try { + lease = await _platform.acquireAccountGate('__notifications__'); + do { + _reconcileAgain = false; + await _reconcileOnce(); + } while (_reconcileAgain); + } finally { + if (lease != null) await _platform.releaseAccountGate(lease); + _reconciling = false; + } + } + + Future _reconcileOnce() async { + final now = DateTime.now().toUtc(); + final horizon = now.add(_schedulingHorizon).millisecondsSinceEpoch; + final eligibleAccounts = { + for (final account in await _database.select(_database.accounts).get()) + if (accountLocalReminderEligibleStates.contains(account.authState)) + account.id, + }; + final settings = _settings(); + final rows = + await (_database.select(_database.notificationSchedule)..where( + (table) => + table.accountId.isIn(eligibleAccounts) & + table.sentAtUtc.isNull() & + table.dismissedAtUtc.isNull() & + table.scheduledAtUtc.isSmallerOrEqualValue(horizon), + )) + .get(); + rows.removeWhere( + (row) => + (row.sourceType == 'event' && !settings.notifyEventReminders) || + (row.sourceType == 'task' && !settings.notifyTaskReminders) || + !const {'event', 'task'}.contains(row.sourceType) || + _effectiveAt(row) <= now.millisecondsSinceEpoch, + ); + rows.sort((a, b) => _effectiveAt(a).compareTo(_effectiveAt(b))); + final desired = rows.take(_maximumScheduledAlarms).toList(); + final desiredIds = {for (final row in desired) row.id}; + final mappings = await _database + .select(_database.androidNotificationMappings) + .get(); + final pendingIds = { + for (final request in await _plugin.pendingNotificationRequests()) + request.id, + }; + + for (final mapping in mappings) { + if (!desiredIds.contains(mapping.scheduleId)) { + await _plugin.cancel(id: mapping.platformId); + await (_database.delete( + _database.androidNotificationMappings, + )..where((table) => table.scheduleId.equals(mapping.scheduleId))).go(); + } + } + + final exact = await canScheduleExactly(); + _precisionDiagnostic = exact + ? 'Exact reminder alarms are enabled.' + : 'Exact alarm access is unavailable; reminders use Android inexact scheduling.'; + if (rows.length > _maximumScheduledAlarms) { + _precisionDiagnostic = + '$_precisionDiagnostic ${rows.length - _maximumScheduledAlarms} later reminders are waiting for scheduling capacity.'; + } + final activeMappings = { + for (final mapping + in await _database + .select(_database.androidNotificationMappings) + .get()) + mapping.scheduleId: mapping, + }; + final usedIds = { + for (final mapping in activeMappings.values) mapping.platformId, + }; + for (final row in desired) { + final effectiveAt = applyAndroidQuietHours(_effectiveAt(row), settings); + var mapping = activeMappings[row.id]; + final unchanged = + mapping != null && + mapping.generation == row.generation && + mapping.scheduledAtUtc == effectiveAt && + pendingIds.contains(mapping.platformId); + if (unchanged) continue; + if (mapping != null) await _plugin.cancel(id: mapping.platformId); + final platformId = + mapping?.platformId ?? allocateAndroidNotificationId(row.id, usedIds); + usedIds.add(platformId); + final payload = _AndroidReminderPayload( + scheduleId: row.id, + generation: row.generation, + sourceType: row.sourceType, + accountId: row.accountId, + sourceId: row.sourceId, + ).encode(); + final strings = _strings(); + await _plugin.zonedSchedule( + id: platformId, + title: + settings.notificationDetailLevel == NotificationDetailLevel.private + ? strings.privateTitle + : row.title, + body: + settings.notificationDetailLevel == NotificationDetailLevel.private + ? null + : row.body, + scheduledDate: tz.TZDateTime.fromMillisecondsSinceEpoch( + tz.local, + effectiveAt, + ), + notificationDetails: NotificationDetails( + android: AndroidNotificationDetails( + androidReminderChannelId, + strings.channelName, + channelDescription: strings.channelDescription, + importance: Importance.high, + priority: Priority.high, + category: AndroidNotificationCategory.reminder, + visibility: + settings.notificationDetailLevel == + NotificationDetailLevel.private + ? NotificationVisibility.private + : NotificationVisibility.public, + actions: [ + AndroidNotificationAction( + androidNotificationActionOpen, + strings.open, + ), + AndroidNotificationAction( + androidNotificationActionSnooze, + strings.snooze, + cancelNotification: true, + ), + AndroidNotificationAction( + androidNotificationActionDismiss, + strings.dismiss, + cancelNotification: true, + ), + ], + ), + ), + androidScheduleMode: exact + ? AndroidScheduleMode.exactAllowWhileIdle + : AndroidScheduleMode.inexactAllowWhileIdle, + payload: payload, + ); + await _database + .into(_database.androidNotificationMappings) + .insertOnConflictUpdate( + AndroidNotificationMappingsCompanion.insert( + scheduleId: row.id, + generation: row.generation, + platformId: platformId, + scheduledAtUtc: effectiveAt, + updatedAtUtc: now.millisecondsSinceEpoch, + ), + ); + } + await _reconcileDailySummary( + settings: settings, + eligibleAccounts: eligibleAccounts, + exact: exact, + pendingIds: pendingIds, + ); + } + + Future _reconcileDailySummary({ + required AppSettings settings, + required Set eligibleAccounts, + required bool exact, + required Set pendingIds, + }) async { + const platformId = 0x425903; + final existing = await _database + .select(_database.androidDailySummarySchedules) + .get(); + if (!settings.notifyDueToday || eligibleAccounts.isEmpty) { + await _plugin.cancel(id: platformId); + await _database.delete(_database.androidDailySummarySchedules).go(); + return; + } + final now = tz.TZDateTime.now(tz.local); + final localDate = + '${now.year.toString().padLeft(4, '0')}-${now.month.toString().padLeft(2, '0')}-${now.day.toString().padLeft(2, '0')}'; + for (final row in existing.where((row) => row.localDate != localDate)) { + await _plugin.cancel(id: row.platformId); + await (_database.delete( + _database.androidDailySummarySchedules, + )..where((table) => table.localDate.equals(row.localDate))).go(); + } + final activeListKeys = { + for (final list + in await (_database.select(_database.taskLists)..where( + (table) => + table.accountId.isIn(eligibleAccounts) & + table.pendingDelete.equals(false) & + table.serverMissing.equals(false), + )) + .get()) + '${list.accountId}\u0000${list.id}', + }; + final tasks = activeListKeys.isEmpty + ? const [] + : await (_database.select(_database.tasks)..where( + (table) => + table.accountId.isIn(eligibleAccounts) & + table.pendingDelete.equals(false) & + (table.deleted.isNull() | table.deleted.equals(false)) & + (table.hidden.isNull() | table.hidden.equals(false)), + )) + .get(); + final count = tasks.where((task) { + if (!activeListKeys.contains( + '${task.accountId}\u0000${task.taskListId}', + )) { + return false; + } + if (task.status == 'completed' || task.status == 'cancelled') { + return false; + } + final due = taskDueAsLocal(task); + return due != null && + due.year == now.year && + due.month == now.month && + due.day == now.day; + }).length; + if (count == 0) { + await _plugin.cancel(id: platformId); + await (_database.delete( + _database.androidDailySummarySchedules, + )..where((table) => table.localDate.equals(localDate))).go(); + return; + } + final current = existing + .where((row) => row.localDate == localDate) + .firstOrNull; + if (current != null && + current.scheduledAtUtc <= DateTime.now().millisecondsSinceEpoch) { + // Android does not report ordinary display delivery. Keeping this + // registration prevents a false delivery claim and duplicate summary. + return; + } + var scheduled = tz.TZDateTime(tz.local, now.year, now.month, now.day, 9); + if (!scheduled.isAfter(now)) { + scheduled = now.add(const Duration(seconds: 10)); + } + final scheduledAt = applyAndroidQuietHours( + scheduled.millisecondsSinceEpoch, + settings, + ); + final unchanged = + current != null && + current.taskCount == count && + current.scheduledAtUtc == scheduledAt && + pendingIds.contains(platformId); + if (unchanged) return; + if (current != null) await _plugin.cancel(id: platformId); + final generation = '$localDate:$count:$scheduledAt'; + final strings = _strings(); + await _plugin.zonedSchedule( + id: platformId, + title: strings.dueTodayTitle, + body: settings.notificationDetailLevel == NotificationDetailLevel.private + ? null + : strings.dueTodayBody(count), + scheduledDate: tz.TZDateTime.fromMillisecondsSinceEpoch( + tz.local, + scheduledAt, + ), + notificationDetails: NotificationDetails( + android: AndroidNotificationDetails( + androidReminderChannelId, + strings.channelName, + channelDescription: strings.channelDescription, + importance: Importance.defaultImportance, + priority: Priority.defaultPriority, + visibility: + settings.notificationDetailLevel == + NotificationDetailLevel.private + ? NotificationVisibility.private + : NotificationVisibility.public, + actions: [ + AndroidNotificationAction( + androidNotificationActionOpen, + strings.open, + ), + ], + ), + ), + androidScheduleMode: exact + ? AndroidScheduleMode.exactAllowWhileIdle + : AndroidScheduleMode.inexactAllowWhileIdle, + payload: _AndroidReminderPayload( + scheduleId: 'due-today:$localDate', + generation: generation, + sourceType: 'summary', + accountId: 'all', + sourceId: localDate, + ).encode(), + ); + await _database + .into(_database.androidDailySummarySchedules) + .insertOnConflictUpdate( + AndroidDailySummarySchedulesCompanion.insert( + localDate: localDate, + platformId: platformId, + generation: generation, + scheduledAtUtc: scheduledAt, + taskCount: count, + updatedAtUtc: DateTime.now().millisecondsSinceEpoch, + ), + ); + } + + Future _foregroundResponse(NotificationResponse response) async { + if (response.actionId == androidNotificationActionSnooze || + response.actionId == androidNotificationActionDismiss) { + await busyMaxNotificationBackgroundResponse(response); + await reconcile(); + return; + } + final activation = _publicActivation( + _AndroidReminderPayload.tryParse(response.payload), + ); + if (activation != null) _activations.add(activation); + } +} + +AndroidNotificationStrings _defaultStrings() => + const AndroidNotificationStrings(); + +String _defaultDueTodayBody(int count) => '$count tasks are due today.'; + +AndroidReminderActivation? _publicActivation(_AndroidReminderPayload? payload) { + if (payload == null) return null; + return AndroidReminderActivation( + scheduleId: payload.scheduleId, + generation: payload.generation, + sourceType: payload.sourceType, + accountId: payload.accountId, + itemId: payload.sourceId, + ); +} + +int applyAndroidQuietHours(int epochMillis, AppSettings settings) { + if (!settings.quietHoursEnabled) return epochMillis; + final start = _minutesOfDay(settings.quietHoursStart); + final end = _minutesOfDay(settings.quietHoursEnd); + if (start == null || end == null || start == end) return epochMillis; + final local = tz.TZDateTime.fromMillisecondsSinceEpoch(tz.local, epochMillis); + final current = local.hour * 60 + local.minute; + final quiet = start < end + ? current >= start && current < end + : current >= start || current < end; + if (!quiet) return epochMillis; + var dayOffset = 0; + if (start > end && current >= start) dayOffset = 1; + return tz.TZDateTime( + tz.local, + local.year, + local.month, + local.day + dayOffset, + end ~/ 60, + end % 60, + ).millisecondsSinceEpoch; +} + +bool _isQuietNow(AppSettings settings) { + final now = DateTime.now().millisecondsSinceEpoch; + return applyAndroidQuietHours(now, settings) != now; +} + +int? _minutesOfDay(String value) { + final parts = value.split(':'); + if (parts.length != 2) return null; + final hour = int.tryParse(parts[0]); + final minute = int.tryParse(parts[1]); + if (hour == null || + minute == null || + hour < 0 || + hour > 23 || + minute < 0 || + minute > 59) { + return null; + } + return hour * 60 + minute; +} + +int _effectiveAt(NotificationScheduleData row) => + row.snoozedUntilUtc ?? row.scheduledAtUtc; + +int allocateAndroidNotificationId(String value, Set used) { + var hash = 0x811c9dc5; + for (final byte in utf8.encode(value)) { + hash = ((hash ^ byte) * 0x01000193) & 0x7fffffff; + } + if (hash == 0) hash = 1; + while (used.contains(hash)) { + hash = hash == 0x7fffffff ? 1 : hash + 1; + } + return hash; +} + +AndroidReminderActivation? parseAndroidReminderActivation(String? payload) => + _publicActivation(_AndroidReminderPayload.tryParse(payload)); + +final class _AndroidReminderPayload { + const _AndroidReminderPayload({ + required this.scheduleId, + required this.generation, + required this.sourceType, + required this.accountId, + required this.sourceId, + }); + + static _AndroidReminderPayload? tryParse(String? value) { + if (value == null || value.length > 2048) return null; + try { + final json = jsonDecode(value); + if (json is! Map) return null; + final map = json.cast(); + final fields = ['schedule', 'generation', 'type', 'account', 'source']; + if (fields.any((key) => (map[key]?.toString().trim().isEmpty ?? true))) { + return null; + } + return _AndroidReminderPayload( + scheduleId: map['schedule']!.toString(), + generation: map['generation']!.toString(), + sourceType: map['type']!.toString(), + accountId: map['account']!.toString(), + sourceId: map['source']!.toString(), + ); + } on Object { + return null; + } + } + + final String scheduleId; + final String generation; + final String sourceType; + final String accountId; + final String sourceId; + + String encode() => jsonEncode({ + 'v': 1, + 'schedule': scheduleId, + 'generation': generation, + 'type': sourceType, + 'account': accountId, + 'source': sourceId, + }); +} diff --git a/lib/src/android/android_time_zone.dart b/lib/src/android/android_time_zone.dart new file mode 100644 index 00000000..569a08e9 --- /dev/null +++ b/lib/src/android/android_time_zone.dart @@ -0,0 +1,15 @@ +import 'package:busymax_android_platform/busymax_android_platform.dart'; + +import '../platform/common/desktop_services.dart'; + +final class AndroidLocalTimeZoneSource implements LocalTimeZoneSource { + const AndroidLocalTimeZoneSource(this._platform); + + final BusyMaxAndroidPlatform _platform; + + @override + String? get diagnostic => null; + + @override + Future currentIanaTimeZone() => _platform.currentTimeZoneId(); +} diff --git a/lib/src/android/presentation/android_schedule_screen.dart b/lib/src/android/presentation/android_schedule_screen.dart new file mode 100644 index 00000000..ee1bbd54 --- /dev/null +++ b/lib/src/android/presentation/android_schedule_screen.dart @@ -0,0 +1,1395 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:busymax_android_platform/busymax_android_platform.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:intl/intl.dart'; + +import '../../app/app_bootstrap.dart'; +import '../../features/accounts/data/accounts_repository.dart'; +import '../../features/calendar/data/calendar_repository.dart'; +import '../../features/calendar/presentation/event_editor_draft.dart'; +import '../../features/maps/application/external_location_launcher.dart'; +import '../../features/schedule/application/saved_schedule_location.dart'; +import '../../features/recurrence/domain/event_recurrence_codec.dart'; +import '../../features/recurrence/domain/recurrence_rule.dart'; +import '../../l10n/l10n.dart'; +import '../../l10n/time_format_scope.dart'; +import '../../providers/busy_provider.dart'; +import '../../schedule/schedule_filters.dart'; +import '../../schedule/schedule_item.dart'; +import '../../schedule/schedule_range.dart'; +import '../../schedule/schedule_view_mode.dart'; +import 'android_settings_screen.dart'; +import 'android_tasks_screen.dart'; + +final _androidScheduleItemsProvider = FutureProvider.autoDispose + .family< + List, + ({DateTime anchor, ScheduleViewMode mode, String query}) + >((ref, key) async { + ref.watch(scheduleDataRevisionProvider); + return ref + .watch(scheduleRepositoryProvider) + .listItems( + range: _rangeFor(key.anchor, key.mode), + filters: ScheduleFilters( + query: key.query, + showCompletedTasks: true, + showNoDateTasks: key.mode == ScheduleViewMode.agenda, + ), + ); + }); + +class AndroidScheduleScreen extends ConsumerStatefulWidget { + const AndroidScheduleScreen({super.key}); + + @override + ConsumerState createState() => + _AndroidScheduleScreenState(); +} + +class _AndroidScheduleScreenState extends ConsumerState { + DateTime _anchor = DateTime.now(); + bool _searching = false; + String _query = ''; + + @override + Widget build(BuildContext context) { + final settings = ref.watch(appSettingsControllerProvider); + final mode = settings.androidScheduleViewMode ?? ScheduleViewMode.agenda; + final items = ref.watch( + _androidScheduleItemsProvider(( + anchor: DateTime(_anchor.year, _anchor.month, _anchor.day), + mode: mode, + query: _query, + )), + ); + return Scaffold( + appBar: AppBar( + title: _searching + ? TextField( + autofocus: true, + decoration: InputDecoration( + hintText: context.l10n.windowsSearch, + border: InputBorder.none, + ), + onChanged: (value) => setState(() => _query = value), + ) + : InkWell( + borderRadius: BorderRadius.circular(12), + onTap: _selectDate, + child: ConstrainedBox( + constraints: const BoxConstraints(minHeight: 48), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 4), + child: Align( + alignment: AlignmentDirectional.centerStart, + child: Text(_periodLabel(context, _anchor, mode)), + ), + ), + ), + ), + actions: [ + IconButton( + tooltip: context.l10n.windowsSearch, + onPressed: () => setState(() { + _searching = !_searching; + if (!_searching) _query = ''; + }), + icon: Icon(_searching ? Icons.close : Icons.search), + ), + IconButton( + tooltip: context.l10n.collectionSettings, + onPressed: () => _showSources(context), + icon: const Icon(Icons.tune), + ), + PopupMenuButton( + tooltip: context.l10n.scheduleDisplaySettings, + initialValue: mode, + onSelected: ref + .read(appSettingsControllerProvider.notifier) + .setAndroidScheduleViewMode, + itemBuilder: (context) => [ + for (final candidate in ScheduleViewMode.values) + PopupMenuItem( + value: candidate, + child: Text(_modeLabel(context, candidate)), + ), + ], + ), + ], + bottom: PreferredSize( + preferredSize: const Size.fromHeight(48), + child: Padding( + padding: const EdgeInsets.fromLTRB(8, 0, 8, 8), + child: Row( + children: [ + IconButton( + tooltip: context.l10n.shortcutPreviousPeriod, + onPressed: () => + setState(() => _anchor = _move(_anchor, mode, -1)), + icon: const Icon(Icons.chevron_left), + ), + FilledButton.tonal( + onPressed: () => setState(() => _anchor = DateTime.now()), + child: Text(context.l10n.today), + ), + IconButton( + tooltip: context.l10n.shortcutNextPeriod, + onPressed: () => + setState(() => _anchor = _move(_anchor, mode, 1)), + icon: const Icon(Icons.chevron_right), + ), + const Spacer(), + Flexible( + child: Text( + _modeLabel(context, mode), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + ), + ), + body: items.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (error, _) => _Message( + icon: Icons.error_outline, + title: context.l10n.scheduleUnavailable, + detail: '$error', + ), + data: (value) => value.isEmpty + ? _Message( + icon: _query.isEmpty ? Icons.event_busy : Icons.search_off, + title: _query.isEmpty + ? context.l10n.scheduleNoSources + : context.l10n.scheduleNoSearchResults, + detail: _query.isEmpty + ? context.l10n.scheduleNoSourcesDescription + : context.l10n.scheduleNoSearchResultsDescription, + ) + : _ScheduleBody( + anchor: _anchor, + mode: mode, + items: value, + onSelectDate: (date) => setState(() => _anchor = date), + onOpen: (item) => _showItem(context, item), + ), + ), + floatingActionButton: FloatingActionButton( + onPressed: () => _createItem(context), + tooltip: context.l10n.create, + child: const Icon(Icons.add), + ), + ); + } + + Future _showSources(BuildContext context) async { + await showModalBottomSheet( + context: context, + showDragHandle: true, + isScrollControlled: true, + builder: (context) => Consumer( + builder: (context, ref, _) { + final sources = ref.watch(calendarSourcesStreamProvider); + final lists = ref.watch(scheduleTaskListsProvider); + final settings = ref.watch(appSettingsControllerProvider); + return SafeArea( + child: SizedBox( + height: MediaQuery.sizeOf(context).height * .72, + child: ListView( + padding: const EdgeInsets.only(bottom: 24), + children: [ + ListTile( + title: Text(context.l10n.collectionSettings), + titleTextStyle: Theme.of(context).textTheme.titleLarge, + ), + ...(sources.valueOrNull ?? const []) + .map( + (source) => CheckboxListTile( + secondary: const Icon(Icons.calendar_month), + title: Text(source.summary), + subtitle: Text(source.provider.displayName), + value: source.selected, + onChanged: (selected) => ref + .read(calendarRepositoryProvider) + .setSourceSelected(source.id, selected == true), + ), + ), + ...(lists.valueOrNull ?? const []).map( + (list) => CheckboxListTile( + secondary: const Icon(Icons.checklist), + title: Text(list.title), + value: settings.isTaskListVisibleInSchedule( + list.accountId, + list.id, + ), + onChanged: (visible) => ref + .read(appSettingsControllerProvider.notifier) + .setTaskListVisibleInSchedule( + accountId: list.accountId, + taskListId: list.id, + visible: visible == true, + ), + ), + ), + const Divider(), + ListTile( + leading: const Icon(Icons.settings_outlined), + title: Text(context.l10n.collectionSettings), + onTap: () { + Navigator.pop(context); + Navigator.of(this.context).push( + MaterialPageRoute( + builder: (_) => const AndroidSettingsScreen(), + ), + ); + }, + ), + ], + ), + ), + ); + }, + ), + ); + } + + Future _selectDate() async { + final selected = await showDatePicker( + context: context, + initialDate: _anchor, + firstDate: DateTime(1900), + lastDate: DateTime(2200), + ); + if (selected != null && mounted) setState(() => _anchor = selected); + } + + Future _createItem(BuildContext context) async { + final type = await showModalBottomSheet( + context: context, + showDragHandle: true, + builder: (context) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + leading: const Icon(Icons.event_outlined), + title: Text(context.l10n.newEvent), + onTap: () => Navigator.pop(context, 'event'), + ), + ListTile( + leading: const Icon(Icons.check_circle_outline), + title: Text(context.l10n.newTask), + onTap: () => Navigator.pop(context, 'task'), + ), + ], + ), + ), + ); + if (!context.mounted || type == null) return; + if (type == 'event') { + await _createEvent(context); + return; + } + final lists = ref.read(scheduleTaskListsProvider).valueOrNull ?? const []; + if (lists.isEmpty) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(context.l10n.noTaskListsSynced))); + return; + } + final accounts = + ref.read(accountsStreamProvider).valueOrNull ?? const []; + final list = await selectAndroidTaskList( + context, + lists, + title: context.l10n.newTask, + ); + if (list == null || !context.mounted) return; + final account = accounts + .where((value) => value.id == list.accountId) + .firstOrNull; + if (account == null) return; + await showAndroidTaskEditor( + context, + ref, + creationList: list, + creationProvider: account.provider, + initialDue: DateTime(_anchor.year, _anchor.month, _anchor.day), + ); + } + + Future _showItem(BuildContext context, ScheduleItem item) async { + await showModalBottomSheet( + context: context, + showDragHandle: true, + isScrollControlled: true, + builder: (sheetContext) => SafeArea( + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 0, 20, 24), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + item.title, + style: Theme.of(context).textTheme.headlineSmall, + ), + const SizedBox(height: 8), + Text(item.sourceName ?? item.provider.displayName), + if (item.start != null) + Text( + formatClockDateTime( + context, + item.start!, + DateFormat.yMMMd().format(item.start!), + ), + ), + if (item case CalendarScheduleItem( + :final location?, + ) when location.isNotEmpty) + ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.place), + title: Text(location), + trailing: const Icon(Icons.open_in_new), + onTap: () => unawaited( + const ExternalLocationLauncher( + platform: _androidLocationPlatform, + ).open(projectedScheduleItemLocationDestination(item)), + ), + ), + if (item case TaskScheduleItem( + :final notes?, + ) when notes.isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: 12), + child: Text(notes), + ), + const SizedBox(height: 16), + if (item.capabilities.canEdit) + FilledButton.icon( + onPressed: () async { + Navigator.pop(sheetContext); + if (item is CalendarScheduleItem) { + await _editEvent(context, item); + } else if (item is TaskScheduleItem) { + await showAndroidTaskEditor(context, ref, task: item); + } + }, + icon: const Icon(Icons.edit), + label: Text( + item is CalendarScheduleItem + ? context.l10n.editEvent + : context.l10n.editTask, + ), + ), + if (item.provider == BusyProvider.nextcloud) ...[ + const SizedBox(height: 8), + OutlinedButton.icon( + onPressed: () => _exportItem(sheetContext, item), + icon: const Icon(Icons.file_download_outlined), + label: Text(context.l10n.export), + ), + ], + ], + ), + ), + ), + ); + } + + Future _exportItem(BuildContext context, ScheduleItem item) async { + try { + final raw = item is CalendarScheduleItem + ? await ref + .read(calendarRepositoryProvider) + .nativeEventExport(item.id) + : item is TaskScheduleItem + ? await ref + .read(tasksRepositoryForAccountProvider(item.accountId)) + .nativeTaskExport(item.sourceId, item.id) + : null; + if (raw == null) return; + final uri = await BusyMaxAndroidPlatform.instance.createDocument( + suggestedName: item is TaskScheduleItem ? 'task.ics' : 'event.ics', + mimeType: 'text/calendar', + bytes: Uint8List.fromList(utf8.encode(raw)), + ); + if (context.mounted && uri != null) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(context.l10n.exportedFile(uri)))); + } + } on Object catch (error) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.exportFailed('$error'))), + ); + } + } + } + + Future _createEvent(BuildContext context) async { + final sources = + (ref.read(calendarSourcesStreamProvider).valueOrNull ?? + const []) + .where((source) => source.capabilities.canCreateEvents) + .toList(); + if (sources.isEmpty) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(context.l10n.noWritableCalendars))); + return; + } + final source = sources.length == 1 + ? sources.single + : await showModalBottomSheet( + context: context, + showDragHandle: true, + builder: (sheetContext) => SafeArea( + child: ListView( + shrinkWrap: true, + children: [ + ListTile(title: Text(context.l10n.newEvent)), + for (final candidate in sources) + ListTile( + leading: const Icon(Icons.calendar_today_outlined), + title: Text(candidate.summary), + subtitle: Text(candidate.provider.displayName), + onTap: () => Navigator.pop(sheetContext, candidate), + ), + ], + ), + ), + ); + if (source == null || !context.mounted) return; + final start = DateTime(_anchor.year, _anchor.month, _anchor.day, 9); + await Navigator.of(context).push( + MaterialPageRoute( + fullscreenDialog: true, + builder: (_) => AndroidEventEditor( + sources: sources, + draft: EventEditorDraft.newEvent( + accountId: source.accountId, + sourceId: source.id, + providerCalendarId: source.providerCalendarId, + start: start, + end: start.add(const Duration(hours: 1)), + ), + ), + ), + ); + } + + Future _editEvent( + BuildContext context, + CalendarScheduleItem item, + ) async { + await showAndroidEventEditor(context, ref, eventId: item.id); + } +} + +Future showAndroidEventEditor( + BuildContext context, + WidgetRef ref, { + required String eventId, +}) async { + final detail = await ref + .read(calendarRepositoryProvider) + .loadEventDetail(eventId); + if (!context.mounted || detail == null) return; + final sources = + (ref.read(calendarSourcesStreamProvider).valueOrNull ?? + const []) + .where((source) => source.capabilities.canCreateEvents) + .toList(); + await Navigator.of(context).push( + MaterialPageRoute( + fullscreenDialog: true, + builder: (_) => AndroidEventEditor( + sources: sources, + draft: EventEditorDraft.fromEventDetail(detail), + ), + ), + ); +} + +ExternalLocationPlatform _androidLocationPlatform() => + ExternalLocationPlatform.android; + +class _ScheduleBody extends StatelessWidget { + const _ScheduleBody({ + required this.anchor, + required this.mode, + required this.items, + required this.onSelectDate, + required this.onOpen, + }); + final DateTime anchor; + final ScheduleViewMode mode; + final List items; + final ValueChanged onSelectDate; + final ValueChanged onOpen; + + @override + Widget build(BuildContext context) => switch (mode) { + ScheduleViewMode.month => _MonthView( + anchor: anchor, + items: items, + onSelectDate: onSelectDate, + onOpen: onOpen, + ), + ScheduleViewMode.year => _YearView( + anchor: anchor, + items: items, + onSelectDate: onSelectDate, + ), + ScheduleViewMode.week => _WeekView( + anchor: anchor, + items: items, + onOpen: onOpen, + ), + ScheduleViewMode.day => _AgendaList( + items: items + .where((item) => item.start != null && _sameDay(item.start!, anchor)) + .toList(), + onOpen: onOpen, + ), + ScheduleViewMode.agenda => _AgendaList(items: items, onOpen: onOpen), + }; +} + +class _AgendaList extends StatelessWidget { + const _AgendaList({required this.items, required this.onOpen}); + final List items; + final ValueChanged onOpen; + @override + Widget build(BuildContext context) => ListView.separated( + padding: const EdgeInsets.fromLTRB(12, 8, 12, 96), + itemCount: items.length, + separatorBuilder: (_, _) => const SizedBox(height: 6), + itemBuilder: (context, index) { + final item = items[index]; + final time = item.start == null + ? context.l10n.noDate + : item.allDay + ? context.l10n.allDay + : BusyMaxTimeFormatScope.of(context).format(item.start!); + return Card( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (index == 0 || + (item.start == null && items[index - 1].start != null) || + (item.start != null && + (items[index - 1].start == null || + !_sameDay(item.start!, items[index - 1].start!)))) + Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), + child: Text( + item.start == null + ? context.l10n.noDate + : DateFormat.yMMMMEEEEd().format(item.start!), + style: Theme.of(context).textTheme.labelLarge, + ), + ), + ListTile( + minVerticalPadding: 12, + leading: item is TaskScheduleItem + ? Icon( + item.completed + ? Icons.check_circle + : Icons.radio_button_unchecked, + ) + : const Icon(Icons.event), + title: Text( + item.title, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + subtitle: Text( + '$time · ${item.sourceName ?? item.provider.displayName}', + ), + onTap: () => onOpen(item), + ), + ], + ), + ); + }, + ); +} + +class _WeekView extends StatelessWidget { + const _WeekView({ + required this.anchor, + required this.items, + required this.onOpen, + }); + final DateTime anchor; + final List items; + final ValueChanged onOpen; + @override + Widget build(BuildContext context) { + final start = ScheduleRange.week(anchor).start; + return LayoutBuilder( + builder: (context, constraints) { + final shown = constraints.maxWidth >= 700 ? 7 : 3; + final offset = constraints.maxWidth >= 700 + ? 0 + : (anchor.difference(start).inDays).clamp(0, 4); + return Row( + children: [ + for (var index = offset; index < offset + shown; index++) + Expanded( + child: Container( + decoration: BoxDecoration( + border: Border( + left: BorderSide(color: Theme.of(context).dividerColor), + ), + ), + child: Column( + children: [ + Padding( + padding: const EdgeInsets.all(8), + child: Text( + DateFormat.E().add_d().format( + start.add(Duration(days: index)), + ), + style: Theme.of(context).textTheme.titleSmall, + ), + ), + Expanded( + child: _AgendaList( + items: items + .where( + (item) => + item.start != null && + _sameDay( + item.start!, + start.add(Duration(days: index)), + ), + ) + .toList(), + onOpen: onOpen, + ), + ), + ], + ), + ), + ), + ], + ); + }, + ); + } +} + +class _MonthView extends StatelessWidget { + const _MonthView({ + required this.anchor, + required this.items, + required this.onSelectDate, + required this.onOpen, + }); + final DateTime anchor; + final List items; + final ValueChanged onSelectDate; + final ValueChanged onOpen; + @override + Widget build(BuildContext context) { + final range = ScheduleRange.month(anchor); + final days = range.end.difference(range.start).inDays; + return LayoutBuilder( + builder: (context, constraints) { + final gridHeight = constraints.maxHeight * .58; + return Column( + children: [ + SizedBox( + height: gridHeight, + child: GridView.builder( + physics: const NeverScrollableScrollPhysics(), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 7, + ), + itemCount: days, + itemBuilder: (context, index) { + final day = range.start.add(Duration(days: index)); + final count = items + .where( + (item) => + item.start != null && _sameDay(item.start!, day), + ) + .length; + final selected = _sameDay(day, anchor); + return InkWell( + onTap: () => onSelectDate(day), + child: Container( + decoration: BoxDecoration( + color: selected + ? Theme.of(context).colorScheme.secondaryContainer + : null, + border: Border.all( + color: Theme.of(context).dividerColor, + width: .4, + ), + ), + padding: const EdgeInsets.all(4), + child: Column( + children: [ + Text( + '${day.day}', + style: TextStyle( + color: day.month == anchor.month + ? null + : Theme.of(context).disabledColor, + ), + ), + if (count > 0) + Text( + '•' * count.clamp(1, 3), + style: TextStyle( + color: Theme.of(context).colorScheme.primary, + ), + ), + ], + ), + ), + ); + }, + ), + ), + Expanded( + child: _AgendaList( + items: items + .where( + (item) => + item.start != null && _sameDay(item.start!, anchor), + ) + .toList(), + onOpen: onOpen, + ), + ), + ], + ); + }, + ); + } +} + +class _YearView extends StatelessWidget { + const _YearView({ + required this.anchor, + required this.items, + required this.onSelectDate, + }); + final DateTime anchor; + final List items; + final ValueChanged onSelectDate; + @override + Widget build(BuildContext context) => GridView.builder( + padding: const EdgeInsets.fromLTRB(12, 8, 12, 96), + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: MediaQuery.sizeOf(context).width >= 700 ? 3 : 2, + childAspectRatio: 1.2, + crossAxisSpacing: 8, + mainAxisSpacing: 8, + ), + itemCount: 12, + itemBuilder: (context, index) { + final month = index + 1; + final count = items.where((item) => item.start?.month == month).length; + return Card( + child: InkWell( + onTap: () => onSelectDate(DateTime(anchor.year, month, 1)), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + DateFormat.MMMM().format(DateTime(anchor.year, month)), + style: Theme.of(context).textTheme.titleMedium, + ), + const Spacer(), + Text( + context.l10n.scheduleItemCount(count), + style: Theme.of(context).textTheme.bodyLarge, + ), + ], + ), + ), + ), + ); + }, + ); +} + +class AndroidEventEditor extends ConsumerStatefulWidget { + const AndroidEventEditor({ + super.key, + required this.sources, + required this.draft, + }); + final List sources; + final EventEditorDraft draft; + @override + ConsumerState createState() => _AndroidEventEditorState(); +} + +class _AndroidEventEditorState extends ConsumerState { + late EventEditorDraft _draft = widget.draft; + late final TextEditingController _title = TextEditingController( + text: _draft.title, + ); + late final TextEditingController _location = TextEditingController( + text: _draft.location, + ); + late final TextEditingController _description = TextEditingController( + text: _draft.description, + ); + late final TextEditingController _guests = TextEditingController( + text: _draft.attendees + .where((a) => !a.self && !a.organizer) + .map((a) => a.email) + .join(', '), + ); + late final TextEditingController _categories = TextEditingController( + text: _draft.categories.join(', '), + ); + int? _reminderMinutes; + late RecurrenceFrequency _frequency = EventRecurrenceCodec.decode( + _source.provider, + _draft.recurrence, + baseDate: _draft.start, + ).frequency; + bool _recurrenceChanged = false; + bool _saving = false; + + @override + void dispose() { + _title.dispose(); + _location.dispose(); + _description.dispose(); + _guests.dispose(); + _categories.dispose(); + super.dispose(); + } + + CalendarSourceEntity get _source => widget.sources.firstWhere( + (source) => source.id == _draft.sourceId, + orElse: () => widget.sources.first, + ); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + leading: const CloseButton(), + title: Text( + _draft.eventId == null + ? context.l10n.newEvent + : context.l10n.editEvent, + ), + actions: [ + TextButton( + onPressed: _saving ? null : _save, + child: Text(context.l10n.save), + ), + ], + ), + body: Form( + child: ListView( + padding: const EdgeInsets.all(16), + children: [ + TextFormField( + controller: _title, + decoration: InputDecoration(labelText: context.l10n.title), + autofocus: _draft.eventId == null, + onChanged: (v) => + setState(() => _draft = _draft.copyWith(title: v)), + ), + const SizedBox(height: 12), + DropdownButtonFormField( + initialValue: _draft.sourceId, + decoration: InputDecoration(labelText: context.l10n.calendar), + items: [ + for (final source in widget.sources) + DropdownMenuItem( + value: source.id, + child: Text(source.summary), + ), + ], + onChanged: _draft.eventId != null + ? null + : (id) { + final source = widget.sources.firstWhere( + (s) => s.id == id, + ); + setState( + () => _draft = _draft.copyWith( + accountId: source.accountId, + sourceId: source.id, + providerCalendarId: source.providerCalendarId, + ), + ); + }, + ), + SwitchListTile( + contentPadding: EdgeInsets.zero, + title: Text(context.l10n.allDay), + value: _draft.allDay, + onChanged: (value) => + setState(() => _draft = _draft.copyWith(allDay: value)), + ), + _DateTimeTile( + label: context.l10n.startDateTime, + value: _draft.start!, + allDay: _draft.allDay, + onChanged: (value) => + setState(() => _draft = _draft.copyWith(start: value)), + ), + _DateTimeTile( + label: context.l10n.endDateTime, + value: _draft.end!, + allDay: _draft.allDay, + onChanged: (value) => + setState(() => _draft = _draft.copyWith(end: value)), + ), + const SizedBox(height: 12), + TextFormField( + controller: _location, + decoration: InputDecoration( + labelText: context.l10n.location, + prefixIcon: const Icon(Icons.place_outlined), + ), + onChanged: (v) => _draft = _draft.copyWith(location: v), + ), + const SizedBox(height: 12), + TextFormField( + controller: _description, + minLines: 3, + maxLines: 7, + decoration: InputDecoration(labelText: context.l10n.description), + onChanged: (v) => _draft = _draft.copyWith(description: v), + ), + const SizedBox(height: 12), + DropdownButtonFormField( + initialValue: _frequency, + decoration: InputDecoration(labelText: context.l10n.repeat), + items: [ + DropdownMenuItem( + value: RecurrenceFrequency.none, + child: Text(context.l10n.repeatNone), + ), + DropdownMenuItem( + value: RecurrenceFrequency.daily, + child: Text(context.l10n.repeatDaily), + ), + DropdownMenuItem( + value: RecurrenceFrequency.weekly, + child: Text(context.l10n.repeatWeekly), + ), + DropdownMenuItem( + value: RecurrenceFrequency.monthly, + child: Text(context.l10n.repeatMonthly), + ), + DropdownMenuItem( + value: RecurrenceFrequency.yearly, + child: Text(context.l10n.repeatYearly), + ), + ], + onChanged: (value) => setState(() { + _frequency = value ?? RecurrenceFrequency.none; + _recurrenceChanged = true; + }), + ), + if (_draft.providerRecurringEventId != null) ...[ + const SizedBox(height: 12), + ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.call_split), + title: Text(context.l10n.chooseRecurringEventScope), + subtitle: _draft.recurringMutationScope == null + ? null + : Text( + _scopeLabel(context, _draft.recurringMutationScope!), + ), + onTap: _selectRecurringScope, + ), + ], + const SizedBox(height: 12), + DropdownButtonFormField( + initialValue: _reminderMinutes, + decoration: InputDecoration(labelText: context.l10n.reminder), + items: [ + DropdownMenuItem( + value: 0, + child: Text(context.l10n.repeatNone), + ), + for (final value in const [5, 10, 30, 60, 1440]) + DropdownMenuItem( + value: value, + child: Text(context.l10n.reminderMinutesBefore(value)), + ), + ], + onChanged: (value) => setState(() => _reminderMinutes = value), + ), + const SizedBox(height: 12), + TextFormField( + controller: _guests, + enabled: _draft.canManageAttendees, + decoration: InputDecoration(labelText: context.l10n.guests), + keyboardType: TextInputType.emailAddress, + ), + const SizedBox(height: 12), + TextFormField( + controller: _categories, + decoration: InputDecoration(labelText: context.l10n.categories), + ), + if (_draft.eventId != null) ...[ + const SizedBox(height: 24), + OutlinedButton.icon( + onPressed: _saving ? null : _delete, + icon: const Icon(Icons.delete_outline), + label: Text(context.l10n.deleteEvent), + ), + ], + const SizedBox(height: 40), + ], + ), + ), + ); + } + + Future _save() async { + final start = _draft.start; + if (_title.text.trim().isEmpty || start == null) return; + var draft = _draft.copyWith( + title: _title.text.trim(), + location: _location.text.trim(), + description: _description.text.trim(), + attendees: [ + for (final address in _guests.text.split(',')) + if (address.trim().isNotEmpty) + EventAttendeeDraft(email: address.trim()), + ], + attendeesChanged: + _guests.text.trim().isNotEmpty || _draft.attendees.isNotEmpty, + categories: _categories.text + .split(',') + .map((v) => v.trim()) + .where((v) => v.isNotEmpty) + .toList(), + categoriesChanged: true, + ); + if (_reminderMinutes != null) { + final value = _reminderMinutes!; + draft = draft.copyWith( + reminders: _eventReminders(_source.provider, value), + remindersChanged: true, + clearReminders: value == 0, + ); + } + if (_recurrenceChanged) { + final rule = _simpleRecurrenceRule(_frequency, start); + draft = draft.copyWith( + recurrence: rule.repeats + ? EventRecurrenceCodec.encode( + _source.provider, + rule, + baseDate: start, + allDay: draft.allDay, + timeZone: draft.startTimeZone, + ) + : null, + recurrenceChanged: true, + clearRecurrence: !rule.repeats, + ); + } + if (!draft.canSave) return; + if (draft.providerRecurringEventId != null && + draft.recurringMutationScope == null) { + final scope = await _selectRecurringScope(); + if (scope == null) return; + draft = draft.copyWith(recurringMutationScope: scope); + } + setState(() => _saving = true); + try { + if (draft.eventId == null) { + await ref.read(calendarRepositoryProvider).createLocalEvent(draft); + } else { + await ref.read(calendarRepositoryProvider).updateLocalEvent(draft); + } + if (mounted) Navigator.pop(context); + } on Object catch (error) { + if (mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('$error'))); + } + } finally { + if (mounted) setState(() => _saving = false); + } + } + + Future _delete() async { + var draft = _draft; + if (draft.providerRecurringEventId != null && + draft.recurringMutationScope == null) { + final scope = await _selectRecurringScope(); + if (scope == null) return; + draft = draft.copyWith(recurringMutationScope: scope); + } + if (!mounted) return; + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(context.l10n.deleteEvent), + content: Text(draft.title), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: Text(context.l10n.cancel), + ), + FilledButton( + onPressed: () => Navigator.pop(context, true), + child: Text(context.l10n.delete), + ), + ], + ), + ); + if (confirmed != true || _draft.eventId == null) return; + setState(() => _saving = true); + try { + await ref + .read(calendarRepositoryProvider) + .deleteLocalEvent( + _draft.eventId!, + recurringScope: draft.recurringMutationScope, + ); + if (mounted) Navigator.pop(context); + } on Object catch (error) { + if (mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('$error'))); + } + } finally { + if (mounted) setState(() => _saving = false); + } + } + + Future _selectRecurringScope() async { + final scope = await showModalBottomSheet( + context: context, + showDragHandle: true, + builder: (context) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile(title: Text(context.l10n.chooseRecurringEventScope)), + for (final value in RecurringEventMutationScope.values) + if (value != RecurringEventMutationScope.thisAndFuture || + supportsThisAndFollowingEventMutation(_source.provider)) + ListTile( + leading: Icon( + _draft.recurringMutationScope == value + ? Icons.radio_button_checked + : Icons.radio_button_unchecked, + ), + title: Text(_scopeLabel(context, value)), + onTap: () => Navigator.pop(context, value), + ), + ], + ), + ), + ); + if (scope != null && mounted) { + setState(() => _draft = _draft.copyWith(recurringMutationScope: scope)); + } + return scope; + } +} + +String _scopeLabel(BuildContext context, RecurringEventMutationScope scope) => + switch (scope) { + RecurringEventMutationScope.entireSeries => context.l10n.entireSeries, + RecurringEventMutationScope.singleOccurrence => + context.l10n.singleOccurrence, + RecurringEventMutationScope.thisAndFuture => + context.l10n.thisAndFollowingEvents, + }; + +RecurrenceRule _simpleRecurrenceRule( + RecurrenceFrequency frequency, + DateTime base, +) { + final weekday = const [ + 'MO', + 'TU', + 'WE', + 'TH', + 'FR', + 'SA', + 'SU', + ][base.weekday - 1]; + return const RecurrenceRule.none().copyWith( + frequency: frequency, + byDay: frequency == RecurrenceFrequency.weekly ? [weekday] : const [], + byMonthDay: + frequency == RecurrenceFrequency.monthly || + frequency == RecurrenceFrequency.yearly + ? [base.day] + : const [], + byMonth: frequency == RecurrenceFrequency.yearly ? [base.month] : const [], + ); +} + +class _DateTimeTile extends StatelessWidget { + const _DateTimeTile({ + required this.label, + required this.value, + required this.allDay, + required this.onChanged, + }); + final String label; + final DateTime value; + final bool allDay; + final ValueChanged onChanged; + @override + Widget build(BuildContext context) => ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.schedule), + title: Text(label), + subtitle: Text( + allDay + ? DateFormat.yMMMd().format(value) + : formatClockDateTime( + context, + value, + DateFormat.yMMMd().format(value), + ), + ), + onTap: () async { + final date = await showDatePicker( + context: context, + initialDate: value, + firstDate: DateTime(1970), + lastDate: DateTime(2200), + ); + if (date == null || !context.mounted) return; + if (allDay) return onChanged(date); + final time = await showTimePicker( + context: context, + initialTime: TimeOfDay.fromDateTime(value), + ); + if (time != null) { + onChanged( + DateTime(date.year, date.month, date.day, time.hour, time.minute), + ); + } + }, + ); +} + +class _Message extends StatelessWidget { + const _Message({ + required this.icon, + required this.title, + required this.detail, + }); + final IconData icon; + final String title; + final String detail; + @override + Widget build(BuildContext context) => Center( + child: SingleChildScrollView( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 52), + const SizedBox(height: 16), + Text( + title, + style: Theme.of(context).textTheme.titleLarge, + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + Text(detail, textAlign: TextAlign.center), + ], + ), + ), + ); +} + +ScheduleRange _rangeFor(DateTime anchor, ScheduleViewMode mode) => + switch (mode) { + ScheduleViewMode.day => ScheduleRange.day(anchor), + ScheduleViewMode.week => ScheduleRange.week(anchor), + ScheduleViewMode.month => ScheduleRange.month(anchor), + ScheduleViewMode.year => ScheduleRange.year(anchor), + ScheduleViewMode.agenda => ScheduleRange( + start: DateTime(anchor.year, anchor.month, anchor.day - 30), + end: DateTime(anchor.year, anchor.month, anchor.day + 91), + ), + }; + +DateTime _move(DateTime date, ScheduleViewMode mode, int amount) => + switch (mode) { + ScheduleViewMode.day || ScheduleViewMode.agenda => DateTime( + date.year, + date.month, + date.day + amount, + ), + ScheduleViewMode.week => DateTime( + date.year, + date.month, + date.day + 7 * amount, + ), + ScheduleViewMode.month => DateTime(date.year, date.month + amount, 1), + ScheduleViewMode.year => DateTime(date.year + amount, date.month, 1), + }; + +String _periodLabel( + BuildContext context, + DateTime date, + ScheduleViewMode mode, +) => switch (mode) { + ScheduleViewMode.day || + ScheduleViewMode.agenda => DateFormat.yMMMMd().format(date), + ScheduleViewMode.week => + '${DateFormat.MMMd().format(ScheduleRange.week(date).start)} – ${DateFormat.MMMd().format(ScheduleRange.week(date).end.subtract(const Duration(days: 1)))}', + ScheduleViewMode.month => DateFormat.yMMMM().format(date), + ScheduleViewMode.year => '${date.year}', +}; + +String _modeLabel(BuildContext context, ScheduleViewMode mode) => + switch (mode) { + ScheduleViewMode.day => context.l10n.viewDay, + ScheduleViewMode.week => context.l10n.viewWeek, + ScheduleViewMode.month => context.l10n.viewMonth, + ScheduleViewMode.year => context.l10n.viewYear, + ScheduleViewMode.agenda => context.l10n.viewAgenda, + }; + +bool _sameDay(DateTime a, DateTime b) => + a.year == b.year && a.month == b.month && a.day == b.day; +Object? _eventReminders(BusyProvider provider, int minutes) { + if (minutes <= 0) return null; + return provider == BusyProvider.microsoft + ? {'isReminderOn': true, 'reminderMinutesBeforeStart': minutes} + : { + 'useDefault': false, + 'overrides': [ + {'method': 'popup', 'minutes': minutes}, + ], + }; +} diff --git a/lib/src/android/presentation/android_settings_screen.dart b/lib/src/android/presentation/android_settings_screen.dart new file mode 100644 index 00000000..de715933 --- /dev/null +++ b/lib/src/android/presentation/android_settings_screen.dart @@ -0,0 +1,1597 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:busymax_android_platform/busymax_android_platform.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:intl/intl.dart'; +import 'package:package_info_plus/package_info_plus.dart'; + +import '../../app/app_bootstrap.dart'; +import '../../calendar_providers/calendar_colors.dart'; +import '../../core/auth/oauth_models.dart'; +import '../../dav/dav_errors.dart'; +import '../../dav/mutation/dav_conflict_repository.dart'; +import '../../features/accounts/data/accounts_repository.dart'; +import '../../features/accounts/domain/account_collection_creation_capabilities.dart'; +import '../../features/calendar/data/calendar_repository.dart'; +import '../../features/task_lists/data/task_lists_repository.dart'; +import '../../features/tasks/domain/task_capabilities.dart'; +import '../../ical/ical_import_service.dart'; +import '../../ical/ical_ingestion.dart'; +import '../../l10n/app_locale.dart'; +import '../../l10n/l10n.dart'; +import '../../providers/busy_provider.dart'; +import '../../webcal/webcal_subscription_service.dart'; +import '../android_background.dart'; +import '../android_notifications.dart'; + +class AndroidSettingsScreen extends ConsumerStatefulWidget { + const AndroidSettingsScreen({super.key}); + + @override + ConsumerState createState() => + _AndroidSettingsScreenState(); +} + +class _AndroidSettingsScreenState extends ConsumerState { + bool _syncing = false; + BusyProvider? _connecting; + final _removing = {}; + + @override + Widget build(BuildContext context) { + final accounts = + ref.watch(accountManagementStreamProvider).valueOrNull ?? const []; + final sources = + ref.watch(calendarSourcesStreamProvider).valueOrNull ?? const []; + final lists = ref.watch(scheduleTaskListsProvider).valueOrNull ?? const []; + final subscriptions = + ref.watch(webCalSubscriptionsProvider).valueOrNull ?? const []; + final conflicts = + ref.watch(davConflictsStreamProvider).valueOrNull ?? const []; + final settings = ref.watch(appSettingsControllerProvider); + + return Scaffold( + appBar: AppBar(title: Text(context.l10n.settings)), + body: ListView( + padding: const EdgeInsets.fromLTRB(12, 8, 12, 96), + children: [ + _Section( + title: context.l10n.accounts, + children: [ + if (accounts.isEmpty) + ListTile( + leading: const Icon(Icons.account_circle_outlined), + title: Text(context.l10n.providerConnectionDescription), + ), + for (final account in accounts) + ListTile( + leading: Icon(_providerIcon(account.provider)), + title: Text(account.displayLabel), + subtitle: Text(_accountSubtitle(account)), + trailing: _removing.contains(account.id) + ? const SizedBox.square( + dimension: 22, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : PopupMenuButton( + onSelected: (value) { + if (value == 'sync') { + unawaited(_syncAccount(account.id)); + } else if (value == 'reconnect') { + unawaited(_connect(account.provider, account)); + } else if (value == 'remove') { + unawaited(_removeAccount(account)); + } + }, + itemBuilder: (context) => [ + PopupMenuItem( + value: 'sync', + child: Text(context.l10n.sync), + ), + if (account.needsReconnect) + PopupMenuItem( + value: 'reconnect', + child: Text(context.l10n.connectAccountAction), + ), + PopupMenuItem( + value: 'remove', + child: Text(context.l10n.removeAccount), + ), + ], + ), + ), + const Divider(height: 1), + _AccountButtons( + connecting: _connecting, + onConnect: (provider) => unawaited(_connect(provider)), + ), + ], + ), + _Section( + title: context.l10n.collectionSettings, + children: [ + if (accounts.any( + (account) => + account.isSignedIn && + account.calendarsEnabled && + accountCollectionCreationModes( + account.provider, + ).calendarMode != + CalendarCollectionCreationMode.unavailable, + )) + ListTile( + leading: const Icon(Icons.add_box_outlined), + title: Text(context.l10n.newCalendar), + onTap: () => + unawaited(_createCollection(accounts, calendar: true)), + ), + if (accounts.any( + (account) => + account.isSignedIn && + account.tasksEnabled && + accountCollectionCreationModes( + account.provider, + ).taskListMode != + TaskListCreationMode.unavailable, + )) + ListTile( + leading: const Icon(Icons.playlist_add), + title: Text(context.l10n.newTaskList), + onTap: () => + unawaited(_createCollection(accounts, calendar: false)), + ), + if (sources.isEmpty && lists.isEmpty) + ListTile(title: Text(context.l10n.noCalendarsSynced)), + for (final source in sources) + ListTile( + leading: const Icon(Icons.calendar_today_outlined), + title: Text(source.summary), + subtitle: Text(source.provider.displayName), + trailing: Switch( + value: source.selected, + onChanged: (value) => unawaited( + ref + .read(calendarRepositoryProvider) + .setSourceSelected(source.id, value), + ), + ), + onTap: () => unawaited(_editCalendarSource(source)), + ), + for (final list in lists) + ListTile( + leading: const Icon(Icons.checklist_outlined), + title: Text(list.title), + subtitle: Text(context.l10n.showInSchedule), + trailing: Switch( + value: settings.isTaskListVisibleInSchedule( + list.accountId, + list.id, + ), + onChanged: (value) => unawaited( + ref + .read(appSettingsControllerProvider.notifier) + .setTaskListVisibleInSchedule( + accountId: list.accountId, + taskListId: list.id, + visible: value, + ), + ), + ), + onTap: () => unawaited(_editTaskList(list, accounts)), + ), + ], + ), + _Section( + title: context.l10n.calendarSubscriptions, + children: [ + ListTile( + leading: const Icon(Icons.add_link), + title: Text(context.l10n.addCalendarSubscription), + subtitle: Text(context.l10n.calendarSubscriptionsDescription), + onTap: () => unawaited(_addSubscription()), + ), + for (final subscription in subscriptions) + ListTile( + leading: const Icon(Icons.public), + title: Text(subscription.name), + subtitle: Text( + subscription.lastFailureCode == null + ? context.l10n.subscriptionSafeOrigin( + subscription.safeOrigin, + ) + : context.l10n.subscriptionStatusIssue( + subscription.lastFailureCode!, + ), + ), + trailing: PopupMenuButton( + onSelected: (value) => + unawaited(_subscriptionAction(subscription, value)), + itemBuilder: (context) => [ + PopupMenuItem( + value: 'refresh', + child: Text(context.l10n.refreshNow), + ), + PopupMenuItem( + value: 'rename', + child: Text(context.l10n.rename), + ), + PopupMenuItem( + value: 'remove', + child: Text(context.l10n.unsubscribe), + ), + ], + ), + ), + ListTile( + leading: const Icon(Icons.file_open_outlined), + title: Text(context.l10n.importIcsFile), + subtitle: Text(context.l10n.calendarImportDescription), + onTap: () => unawaited(showAndroidIcsImport(context, ref)), + ), + ], + ), + _Section( + title: context.l10n.appearance, + children: [ + ListTile( + leading: const Icon(Icons.palette_outlined), + title: Text(context.l10n.theme), + trailing: DropdownButton( + value: settings.themeModePreference, + onChanged: (value) { + if (value != null) { + unawaited( + ref + .read(appSettingsControllerProvider.notifier) + .setThemeModePreference(value), + ); + } + }, + items: [ + DropdownMenuItem( + value: BusyMaxThemeModePreference.system, + child: Text(context.l10n.themeSystem), + ), + DropdownMenuItem( + value: BusyMaxThemeModePreference.light, + child: Text(context.l10n.themeLight), + ), + DropdownMenuItem( + value: BusyMaxThemeModePreference.dark, + child: Text(context.l10n.themeDark), + ), + ], + ), + ), + ListTile( + leading: const Icon(Icons.language), + title: Text(context.l10n.currentLocale), + trailing: DropdownButton( + value: settings.localeTag ?? 'system', + onChanged: (value) => unawaited( + ref + .read(appSettingsControllerProvider.notifier) + .setLocaleTag(value == 'system' ? null : value), + ), + items: [ + DropdownMenuItem( + value: 'system', + child: Text(context.l10n.themeSystem), + ), + for (final option in busyMaxLocaleOptions) + DropdownMenuItem( + value: option.tag, + child: Text(option.endonym), + ), + ], + ), + ), + ListTile( + leading: const Icon(Icons.schedule), + title: Text(context.l10n.timeFormat), + trailing: DropdownButton( + value: settings.timeFormatPreference, + onChanged: (value) { + if (value != null) { + unawaited( + ref + .read(appSettingsControllerProvider.notifier) + .setTimeFormatPreference(value), + ); + } + }, + items: [ + DropdownMenuItem( + value: BusyMaxTimeFormatPreference.system, + child: Text(context.l10n.themeSystem), + ), + DropdownMenuItem( + value: BusyMaxTimeFormatPreference.twelveHour, + child: Text(context.l10n.timeFormatTwelveHour), + ), + DropdownMenuItem( + value: BusyMaxTimeFormatPreference.twentyFourHour, + child: Text(context.l10n.timeFormatTwentyFourHour), + ), + ], + ), + ), + ], + ), + _Section( + title: context.l10n.notifications, + children: [ + _NotificationSwitch( + title: context.l10n.eventReminders, + value: settings.notifyEventReminders, + onChanged: (value) => _setNotificationSetting( + value, + ref + .read(appSettingsControllerProvider.notifier) + .setNotifyEventReminders, + ), + ), + _NotificationSwitch( + title: context.l10n.taskReminders, + value: settings.notifyTaskReminders, + onChanged: (value) => _setNotificationSetting( + value, + ref + .read(appSettingsControllerProvider.notifier) + .setNotifyTaskReminders, + ), + ), + SwitchListTile( + secondary: const Icon(Icons.today_outlined), + title: Text(context.l10n.notifyDueToday), + value: settings.notifyDueToday, + onChanged: (value) => unawaited( + _setNotificationSetting( + value, + ref + .read(appSettingsControllerProvider.notifier) + .setNotifyDueToday, + ), + ), + ), + _NotificationSwitch( + title: context.l10n.notifySyncFailures, + value: settings.notifySyncFailures, + onChanged: (value) => _setNotificationSetting( + value, + ref + .read(appSettingsControllerProvider.notifier) + .setNotifySyncFailures, + ), + ), + _NotificationSwitch( + title: context.l10n.notifyConflicts, + value: settings.notifyConflicts, + onChanged: (value) => _setNotificationSetting( + value, + ref + .read(appSettingsControllerProvider.notifier) + .setNotifyConflicts, + ), + ), + SwitchListTile( + secondary: const Icon(Icons.visibility_off_outlined), + title: Text(context.l10n.notificationDetailLevel), + subtitle: Text(context.l10n.notificationDetailPrivate), + value: + settings.notificationDetailLevel == + NotificationDetailLevel.private, + onChanged: (value) => unawaited( + ref + .read(appSettingsControllerProvider.notifier) + .setNotificationDetailLevel( + value + ? NotificationDetailLevel.private + : NotificationDetailLevel.normal, + ), + ), + ), + ListTile( + leading: const Icon(Icons.alarm), + title: Text(context.l10n.reminders), + subtitle: Text( + ref + .read(androidNotificationServiceProvider) + .precisionDiagnostic, + ), + trailing: TextButton( + onPressed: () => unawaited(_requestExactAlarms()), + child: Text(context.l10n.settingsSystem), + ), + ), + SwitchListTile( + secondary: const Icon(Icons.bedtime_outlined), + title: Text(context.l10n.quietHours), + subtitle: Text(context.l10n.quietHoursDescription), + value: settings.quietHoursEnabled, + onChanged: (value) => unawaited( + ref + .read(appSettingsControllerProvider.notifier) + .setQuietHoursEnabled(value) + .then( + (_) => ref + .read(notificationReconcilerProvider) + .reconcile(), + ), + ), + ), + if (settings.quietHoursEnabled) ...[ + ListTile( + leading: const Icon(Icons.nights_stay_outlined), + title: Text(context.l10n.quietHoursStart), + trailing: Text(settings.quietHoursStart), + onTap: () => unawaited(_pickQuietTime(true)), + ), + ListTile( + leading: const Icon(Icons.wb_sunny_outlined), + title: Text(context.l10n.quietHoursEnd), + trailing: Text(settings.quietHoursEnd), + onTap: () => unawaited(_pickQuietTime(false)), + ), + ], + ListTile( + leading: const Icon(Icons.notifications_active_outlined), + title: Text(context.l10n.notifications), + trailing: const Icon(Icons.open_in_new), + onTap: () => unawaited( + BusyMaxAndroidPlatform.instance.openNotificationSettings(), + ), + ), + ], + ), + _Section( + title: context.l10n.sync, + children: [ + ListTile( + leading: _syncing + ? const SizedBox.square( + dimension: 24, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.sync), + title: Text(context.l10n.sync), + subtitle: Text(context.l10n.davCachedOfflineNotice), + enabled: !_syncing, + onTap: _syncing ? null : () => unawaited(_syncAll()), + ), + ListTile( + leading: const Icon(Icons.battery_saver_outlined), + title: Text(context.l10n.runInBackgroundWhenClosed), + subtitle: Text(context.l10n.launchAtLoginDescription), + onTap: () => unawaited(enqueueImmediateBusyMaxSync()), + ), + ], + ), + _Section( + title: context.l10n.syncConflicts, + children: [ + if (conflicts.isEmpty) + ListTile( + leading: const Icon(Icons.check_circle_outline), + title: Text(context.l10n.noBlockedPendingOperations), + ), + for (final conflict in conflicts) + ListTile( + leading: const Icon(Icons.warning_amber), + title: Text(conflict.itemTitle), + subtitle: Text( + '${conflict.accountLabel} · ${conflict.collectionName}\n${conflict.localEditSummary}', + ), + isThreeLine: true, + onTap: () => unawaited(_resolveConflict(conflict)), + ), + ], + ), + _Section( + title: context.l10n.privacy, + children: [ + SwitchListTile( + secondary: const Icon(Icons.shield_outlined), + title: Text(context.l10n.redactTaskContentInDiagnostics), + value: settings.redactTaskContentInDiagnostics, + onChanged: (value) => unawaited( + ref + .read(appSettingsControllerProvider.notifier) + .setRedactTaskContentInDiagnostics(value), + ), + ), + ListTile( + leading: const Icon(Icons.privacy_tip_outlined), + title: Text(context.l10n.privacy), + enabled: ref + .read(buildConfigProvider) + .privacyPolicyUrl + .isNotEmpty, + onTap: () => unawaited(_openConfiguredLink('privacy')), + ), + ListTile( + leading: const Icon(Icons.support_agent_outlined), + title: Text(context.l10n.website), + enabled: ref.read(buildConfigProvider).supportUrl.isNotEmpty, + onTap: () => unawaited(_openConfiguredLink('support')), + ), + ListTile( + leading: const Icon(Icons.home_outlined), + title: Text(context.l10n.website), + onTap: () => unawaited(_openConfiguredLink('homepage')), + ), + ListTile( + leading: const Icon(Icons.info_outline), + title: Text(context.l10n.aboutBusyMax), + subtitle: Text(context.l10n.aboutBusyMaxDescription), + onTap: _showAbout, + ), + ], + ), + ], + ), + ); + } + + String _accountSubtitle(AccountEntity account) { + final state = account.isSignedIn + ? context.l10n.signedInAccount + : account.authState; + final synced = account.lastSuccessfulSyncAtUtc; + return synced == null + ? '${account.provider.displayName} · $state' + : '${account.provider.displayName} · $state\n${context.l10n.davLastSuccessfulSync(DateFormat.yMd().add_jm().format(synced.toLocal()))}'; + } + + Future _connect( + BusyProvider provider, [ + AccountEntity? reconnecting, + ]) async { + if (_connecting != null) return; + String? email; + String? password; + String? server; + if (provider == BusyProvider.appleICloud) { + final input = await _appleCredentials(reconnecting?.email); + if (input == null) return; + (email, password) = input; + } else if (provider == BusyProvider.nextcloud) { + server = await _textPrompt( + title: context.l10n.connectNextcloudTitle, + label: context.l10n.nextcloudServerUrl, + initialValue: reconnecting?.authority, + helper: context.l10n.nextcloudBrowserAuthorizationHelp, + ); + if (server == null) return; + final uri = Uri.tryParse(server); + if (uri != null && _isLocalHost(uri.host)) { + await BusyMaxAndroidPlatform.instance.requestLocalNetworkAccess(); + } + } + setState(() => _connecting = provider); + try { + String? accountId; + switch (provider) { + case BusyProvider.google: + accountId = + (await ref.read(authRepositoryProvider).signIn()).accountId; + case BusyProvider.microsoft: + accountId = + (await ref.read(authRepositoryProvider).signInWithMicrosoft()) + .accountId; + case BusyProvider.appleICloud: + final service = ref.read(davAccountOnboardingServiceProvider); + accountId = reconnecting == null + ? (await service.connectAppleICloud( + email: email!, + appSpecificPassword: password!, + )).accountId + : (await service.replaceAppleAppSpecificPassword( + accountId: reconnecting.id, + appSpecificPassword: password!, + )).accountId; + case BusyProvider.nextcloud: + final service = ref.read(davAccountOnboardingServiceProvider); + accountId = reconnecting == null + ? (await service.connectNextcloud( + enteredServer: server!, + )).accountId + : (await service.reconnectNextcloud( + accountId: reconnecting.id, + enteredServer: server!, + )).accountId; + case BusyProvider.webCal: + return; + } + if (accountId != null) unawaited(_syncAccount(accountId)); + } on Object catch (error) { + if (error is OAuthException && error.code == 'OAuthSignInCancelled') { + return; + } + if (error is DavException && error.kind == DavErrorKind.cancelled) return; + _message('$error'); + } finally { + if (mounted) setState(() => _connecting = null); + } + } + + Future<(String, String)?> _appleCredentials(String? fixedEmail) async { + final email = TextEditingController(text: fixedEmail); + final password = TextEditingController(); + final result = await showDialog<(String, String)>( + context: context, + builder: (dialogContext) => AlertDialog( + title: Text(context.l10n.connectAppleICloudTitle), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField( + controller: email, + enabled: fixedEmail == null, + keyboardType: TextInputType.emailAddress, + decoration: InputDecoration( + labelText: context.l10n.appleAccountEmail, + ), + ), + const SizedBox(height: 12), + TextField( + controller: password, + obscureText: true, + decoration: InputDecoration( + labelText: context.l10n.appleAppSpecificPassword, + helperText: context.l10n.appleAppSpecificPasswordHelp, + ), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext), + child: Text(context.l10n.cancel), + ), + FilledButton( + onPressed: () { + if (email.text.trim().isNotEmpty && + password.text.trim().isNotEmpty) { + Navigator.pop(dialogContext, ( + email.text.trim(), + password.text.trim(), + )); + } + }, + child: Text(context.l10n.connectAccountAction), + ), + ], + ), + ); + email.dispose(); + password.dispose(); + return result; + } + + Future _removeAccount(AccountEntity account) async { + final l10n = context.l10n; + var revoke = false; + final confirmed = await showDialog( + context: context, + builder: (dialogContext) => StatefulBuilder( + builder: (context, setDialogState) => AlertDialog( + title: Text(context.l10n.removeAccountTitle(account.displayLabel)), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text(context.l10n.removeAccountConfirmation), + if (account.provider == BusyProvider.google) + CheckboxListTile( + contentPadding: EdgeInsets.zero, + title: Text(context.l10n.revokeGoogleAccess), + subtitle: Text(context.l10n.revokeGoogleAccessDescription), + value: revoke, + onChanged: (value) => + setDialogState(() => revoke = value == true), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext, false), + child: Text(context.l10n.cancel), + ), + FilledButton( + onPressed: () => Navigator.pop(dialogContext, true), + child: Text(context.l10n.removeAccountAction), + ), + ], + ), + ), + ); + if (confirmed != true) return; + setState(() => _removing.add(account.id)); + try { + await ref.read(crossEngineAccountGateProvider).run(account.id, () async { + if (account.provider == BusyProvider.appleICloud || + account.provider == BusyProvider.nextcloud) { + await ref + .read(davAccountOnboardingServiceProvider) + .removeAccount(account.id); + } else { + await ref + .read(authRepositoryProvider) + .removeAccount( + accountId: account.id, + revokeAuthorization: revoke, + ); + } + }); + await ref.read(notificationReconcilerProvider).reconcile(); + } on Object { + _message(l10n.removeAccountFailed); + } finally { + if (mounted) setState(() => _removing.remove(account.id)); + } + } + + Future _syncAll() async { + final l10n = context.l10n; + setState(() => _syncing = true); + try { + await ref.read(allAccountsSyncRunnerProvider)(); + await ref.read(notificationReconcilerProvider).reconcile(); + _message(l10n.syncComplete); + } on Object catch (error) { + _message(l10n.syncFailed('$error')); + } finally { + if (mounted) setState(() => _syncing = false); + } + } + + Future _syncAccount(String accountId) async { + final l10n = context.l10n; + try { + await ref.read(signedInSyncRunnerProvider)(accountId, true); + await ref.read(notificationReconcilerProvider).reconcile(); + _message(l10n.syncComplete); + } on Object catch (error) { + _message(l10n.syncFailed('$error')); + } + } + + Future _editCalendarSource(CalendarSourceEntity source) async { + final l10n = context.l10n; + final action = await showModalBottomSheet( + context: context, + showDragHandle: true, + builder: (context) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile(title: Text(source.summary)), + if (source.capabilities.canRenameCalendar) + ListTile( + leading: const Icon(Icons.edit_outlined), + title: Text(context.l10n.rename), + onTap: () => Navigator.pop(context, 'rename'), + ), + if (source.capabilities.canChangeCalendarColor) + ListTile( + leading: const Icon(Icons.palette_outlined), + title: Text(context.l10n.calendarColor), + onTap: () => Navigator.pop(context, 'color'), + ), + ListTile( + leading: const Icon(Icons.notifications_outlined), + title: Text(context.l10n.reminders), + onTap: () => Navigator.pop(context, 'reminders'), + ), + if (source.provider == BusyProvider.nextcloud && + source.davCollectionId != null) + ListTile( + leading: const Icon(Icons.file_download_outlined), + title: Text(context.l10n.export), + onTap: () => Navigator.pop(context, 'export'), + ), + if (source.capabilities.canRemoveCalendar) + ListTile( + leading: const Icon(Icons.delete_outline), + title: Text(context.l10n.delete), + textColor: Theme.of(context).colorScheme.error, + iconColor: Theme.of(context).colorScheme.error, + onTap: () => Navigator.pop(context, 'delete'), + ), + ], + ), + ), + ); + if (action == null) return; + try { + if (action == 'rename') { + final name = await _textPrompt( + title: l10n.calendar, + label: l10n.setCustomCalendarName, + initialValue: source.summary, + ); + if (name != null && name != source.summary) { + await ref + .read(calendarRepositoryProvider) + .renameLocalSource(source.id, name); + } + } else if (action == 'reminders') { + final reminders = await _booleanPrompt( + l10n.reminders, + source.remindersEnabled, + ); + if (reminders == null) return; + await ref + .read(calendarRepositoryProvider) + .setSourceRemindersEnabled(source.id, reminders); + } else if (action == 'color') { + final choice = await _selectCalendarColor(source); + if (choice == null) return; + await ref + .read(calendarRepositoryProvider) + .setSourceColor(source.id, choice); + } else if (action == 'export') { + await _exportCollection(source.accountId, source.davCollectionId!); + } else if (action == 'delete') { + final remove = + source.capabilities.removalMode == + CalendarRemovalMode.removeFromList; + final confirmed = await _confirm( + remove ? l10n.removeFromMyCalendars : l10n.delete, + remove + ? l10n.removeCalendarConfirmation(source.summary) + : l10n.deleteCalendarConfirmation(source.summary), + remove ? l10n.removeAction : l10n.delete, + ); + if (!confirmed) return; + await ref.read(calendarRepositoryProvider).deleteLocalSource(source.id); + } + } on Object catch (error) { + _message(l10n.calendarUpdateFailed('$error')); + } + } + + Future _editTaskList( + TaskListEntity list, + List accounts, + ) async { + final l10n = context.l10n; + final account = accounts + .where((item) => item.id == list.accountId) + .firstOrNull; + final davCapabilities = list.davCollectionId == null + ? null + : await ref.read( + davTaskCollectionCapabilitiesProvider(( + accountId: list.accountId, + taskListId: list.id, + )).future, + ); + if (!mounted) return; + final capabilities = list.davCollectionId != null + ? davCapabilities ?? noTaskCollectionCapabilities + : account == null + ? noTaskCollectionCapabilities + : adapterDefaultTaskCapabilities(account.provider); + final canRename = account?.provider == BusyProvider.microsoft + ? list.canRenameOrDeleteForMicrosoft + : capabilities.supportsListRename; + final canDelete = account?.provider == BusyProvider.microsoft + ? list.canRenameOrDeleteForMicrosoft + : capabilities.supportsListDelete; + final action = await showModalBottomSheet( + context: context, + showDragHandle: true, + builder: (context) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile(title: Text(list.title)), + if (canRename) + ListTile( + leading: const Icon(Icons.edit_outlined), + title: Text(context.l10n.rename), + onTap: () => Navigator.pop(context, 'rename'), + ), + ListTile( + leading: const Icon(Icons.notifications_outlined), + title: Text(context.l10n.reminders), + onTap: () => Navigator.pop(context, 'reminders'), + ), + if (list.davCollectionId != null && + capabilities.supportsNativeExport) + ListTile( + leading: const Icon(Icons.file_download_outlined), + title: Text(context.l10n.export), + onTap: () => Navigator.pop(context, 'export'), + ), + if (canDelete) + ListTile( + leading: const Icon(Icons.delete_outline), + title: Text(context.l10n.deleteList), + textColor: Theme.of(context).colorScheme.error, + iconColor: Theme.of(context).colorScheme.error, + onTap: () => Navigator.pop(context, 'delete'), + ), + ], + ), + ), + ); + if (action == null) return; + try { + if (action == 'rename') { + final name = await _textPrompt( + title: l10n.taskLists, + label: l10n.list, + initialValue: list.title, + ); + if (name != null && name != list.title) { + await ref + .read(taskListsRepositoryForAccountProvider(list.accountId)) + .renameTaskList(list.id, name); + } + } else if (action == 'reminders') { + final reminders = await _booleanPrompt( + l10n.reminders, + list.remindersEnabled, + ); + if (reminders == null) return; + await ref + .read(taskListsRepositoryForAccountProvider(list.accountId)) + .setRemindersEnabled(list.id, reminders); + } else if (action == 'export') { + await _exportCollection(list.accountId, list.davCollectionId!); + } else if (action == 'delete') { + final confirmed = await _confirm( + l10n.deleteList, + list.isMixedDavCollection + ? l10n.nextcloudRemoveMixed + : l10n.deleteTaskListConfirmation(list.title), + l10n.delete, + ); + if (!confirmed) return; + await ref + .read(taskListsRepositoryForAccountProvider(list.accountId)) + .deleteTaskList(list.id); + } + } on Object catch (error) { + _message(l10n.taskListRenameFailed('$error')); + } + } + + Future _createCollection( + List accounts, { + required bool calendar, + }) async { + final l10n = context.l10n; + final eligible = accounts + .where((account) { + final modes = accountCollectionCreationModes(account.provider); + return account.isSignedIn && + (calendar + ? account.calendarsEnabled && + modes.calendarMode != + CalendarCollectionCreationMode.unavailable + : account.tasksEnabled && + modes.taskListMode != TaskListCreationMode.unavailable); + }) + .toList(growable: false); + if (eligible.isEmpty) return; + final account = eligible.length == 1 + ? eligible.single + : await showModalBottomSheet( + context: context, + showDragHandle: true, + builder: (sheetContext) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + title: Text( + calendar + ? context.l10n.newCalendar + : context.l10n.newTaskList, + ), + ), + for (final candidate in eligible) + ListTile( + leading: Icon(_providerIcon(candidate.provider)), + title: Text(candidate.displayLabel), + subtitle: Text(candidate.provider.displayName), + onTap: () => Navigator.pop(sheetContext, candidate), + ), + ], + ), + ), + ); + if (account == null || !mounted) return; + final title = await _textPrompt( + title: calendar ? l10n.newCalendar : l10n.newTaskList, + label: l10n.title, + ); + if (title == null || !mounted) return; + try { + if (calendar) { + await ref + .read(calendarCollectionCreationServiceProvider) + .createCalendar(accountId: account.id, title: title); + } else { + await ref + .read(taskListsRepositoryForAccountProvider(account.id)) + .createTaskList(title); + } + } on Object catch (error) { + _message( + calendar + ? l10n.calendarCreateFailed('$error') + : l10n.taskListCreateFailed('$error'), + ); + } + } + + Future _selectCalendarColor( + CalendarSourceEntity source, + ) { + final choices = calendarColorChoices(source.provider); + return showModalBottomSheet( + context: context, + showDragHandle: true, + builder: (sheetContext) => SafeArea( + child: ListView( + shrinkWrap: true, + children: [ + ListTile(title: Text(context.l10n.calendarColor)), + for (var index = 0; index < choices.length; index++) + ListTile( + leading: CircleAvatar( + backgroundColor: _colorFromHex( + choices[index].backgroundColor, + ), + ), + title: Text(context.l10n.calendarColorOption(index + 1)), + trailing: + choices[index].backgroundColor.toLowerCase() == + source.backgroundColor?.toLowerCase() + ? const Icon(Icons.check) + : null, + onTap: () => Navigator.pop(sheetContext, choices[index]), + ), + ], + ), + ), + ); + } + + Future _exportCollection(String accountId, String collectionId) async { + final l10n = context.l10n; + final resources = await ref + .read(nextcloudNativeExportServiceProvider) + .collection(accountId, collectionId); + final folder = await BusyMaxAndroidPlatform.instance.exportDocumentTree( + folderName: + 'BusyMax-export-${DateTime.now().toUtc().millisecondsSinceEpoch}', + resources: [ + for (var index = 0; index < resources.length; index++) + AndroidExportResource( + name: 'resource-${index + 1}.ics', + bytes: Uint8List.fromList(utf8.encode(resources[index].rawIcs)), + ), + ], + ); + if (folder != null) _message(l10n.exportedFile(folder)); + } + + Future _addSubscription() async { + final l10n = context.l10n; + final url = await _textPrompt( + title: l10n.addCalendarSubscription, + label: l10n.subscriptionUrl, + helper: l10n.subscriptionUrlHelp, + ); + if (url == null) return; + final name = await _textPrompt( + title: l10n.addCalendarSubscription, + label: l10n.subscriptionName, + ); + try { + await ref + .read(webCalSubscriptionServiceProvider) + .addSubscription(subscriptionUrl: url, localName: name); + } on Object catch (error) { + _message(l10n.subscriptionOperationFailed('$error')); + } + } + + Future _subscriptionAction( + WebCalSubscriptionEntity subscription, + String action, + ) async { + final l10n = context.l10n; + try { + final service = ref.read(webCalSubscriptionServiceProvider); + if (action == 'refresh') { + await service.refreshSubscription(subscription.id, force: true); + } else if (action == 'rename') { + final name = await _textPrompt( + title: l10n.rename, + label: l10n.subscriptionName, + initialValue: subscription.name, + ); + if (name != null) { + await service.renameSubscription(subscription.id, name); + } + } else if (action == 'remove') { + final confirmed = await _confirm( + l10n.unsubscribeCalendarTitle(subscription.name), + l10n.unsubscribeCalendarConfirmation, + l10n.unsubscribe, + ); + if (confirmed) { + await ref + .read(crossEngineAccountGateProvider) + .run( + subscription.accountId, + () => service.unsubscribe(subscription.id), + ); + } + } + } on Object catch (error) { + _message(l10n.subscriptionOperationFailed('$error')); + } + } + + Future _resolveConflict(DavConflictEntity conflict) async { + final l10n = context.l10n; + final resolution = await showModalBottomSheet( + context: context, + showDragHandle: true, + builder: (context) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + title: Text(context.l10n.syncConflicts), + subtitle: Text(conflict.itemTitle), + ), + if (conflict.canKeepServer) + ListTile( + leading: const Icon(Icons.cloud_done_outlined), + title: Text(context.l10n.keepServerVersion), + onTap: () => + Navigator.pop(context, DavConflictResolution.keepServer), + ), + if (conflict.canReapplyLocal) + ListTile( + leading: const Icon(Icons.redo), + title: Text(context.l10n.reapplyLocalChange), + onTap: () => + Navigator.pop(context, DavConflictResolution.reapplyLocal), + ), + if (conflict.canDuplicate) + ListTile( + leading: const Icon(Icons.copy_outlined), + title: Text(context.l10n.duplicateLocalItem), + onTap: () => Navigator.pop( + context, + DavConflictResolution.duplicateLocal, + ), + ), + ], + ), + ), + ); + if (resolution == null) return; + try { + await ref + .read(davConflictResolutionServiceProvider) + .resolve(conflict.id, resolution); + await ref + .read(accountSyncOperationsProvider) + .syncAccount(conflict.accountId, full: false); + } on Object { + _message(l10n.conflictResolutionFailed); + } + } + + Future _setNotificationSetting( + bool enabled, + Future Function(bool) save, + ) async { + if (enabled) { + final granted = await ref + .read(androidNotificationServiceProvider) + .requestNotificationPermission(); + if (!granted) return; + } + await save(enabled); + await ref.read(notificationReconcilerProvider).reconcile(); + if (mounted) setState(() {}); + } + + Future _requestExactAlarms() async { + await ref + .read(androidNotificationServiceProvider) + .requestExactAlarmPermission(); + await ref.read(notificationReconcilerProvider).reconcile(); + if (mounted) setState(() {}); + } + + Future _pickQuietTime(bool start) async { + final settings = ref.read(appSettingsControllerProvider); + final raw = start ? settings.quietHoursStart : settings.quietHoursEnd; + final parts = raw.split(':'); + final picked = await showTimePicker( + context: context, + initialTime: TimeOfDay( + hour: int.tryParse(parts.first) ?? 0, + minute: parts.length > 1 ? int.tryParse(parts[1]) ?? 0 : 0, + ), + ); + if (picked == null) return; + final value = + '${picked.hour.toString().padLeft(2, '0')}:${picked.minute.toString().padLeft(2, '0')}'; + final controller = ref.read(appSettingsControllerProvider.notifier); + if (start) { + await controller.setQuietHoursStart(value); + } else { + await controller.setQuietHoursEnd(value); + } + await ref.read(notificationReconcilerProvider).reconcile(); + } + + Future _openConfiguredLink(String kind) async { + final config = ref.read(buildConfigProvider); + final raw = switch (kind) { + 'privacy' => config.privacyPolicyUrl, + 'support' => config.supportUrl, + _ => config.homepageUrl, + }; + final uri = Uri.tryParse(raw); + if (uri != null) { + await BusyMaxAndroidPlatform.instance.launchExternalUri(uri); + } + } + + Future _showAbout() async { + final info = await PackageInfo.fromPlatform(); + if (!mounted) return; + final build = info.buildNumber.trim(); + showAboutDialog( + context: context, + applicationName: info.appName.isEmpty ? 'BusyMax' : info.appName, + applicationVersion: build.isEmpty + ? info.version + : '${info.version}+$build', + applicationLegalese: '© BusyStack', + ); + } + + Future _textPrompt({ + required String title, + required String label, + String? initialValue, + String? helper, + }) async { + final controller = TextEditingController(text: initialValue); + final value = await showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: Text(title), + content: TextField( + controller: controller, + autofocus: true, + decoration: InputDecoration(labelText: label, helperText: helper), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext), + child: Text(context.l10n.cancel), + ), + FilledButton( + onPressed: () { + final text = controller.text.trim(); + if (text.isNotEmpty) Navigator.pop(dialogContext, text); + }, + child: Text(context.l10n.save), + ), + ], + ), + ); + controller.dispose(); + return value; + } + + Future _booleanPrompt(String title, bool value) { + return showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: Text(title), + content: Text(context.l10n.notificationDetailLevel), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext, false), + child: Text(context.l10n.hide), + ), + FilledButton( + onPressed: () => Navigator.pop(dialogContext, true), + child: Text(context.l10n.show), + ), + ], + ), + ); + } + + Future _confirm(String title, String body, String action) async => + await showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: Text(title), + content: Text(body), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext, false), + child: Text(context.l10n.cancel), + ), + FilledButton( + onPressed: () => Navigator.pop(dialogContext, true), + child: Text(action), + ), + ], + ), + ) ?? + false; + + bool _isLocalHost(String host) { + final value = host.toLowerCase(); + if (value == 'localhost' || value.endsWith('.local')) return true; + final parts = value.split('.').map(int.tryParse).toList(); + if (parts.length != 4 || parts.any((part) => part == null)) return false; + return parts[0] == 10 || + (parts[0] == 192 && parts[1] == 168) || + (parts[0] == 172 && parts[1]! >= 16 && parts[1]! <= 31); + } + + void _message(String message) { + if (!mounted) return; + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(message))); + } +} + +class _Section extends StatelessWidget { + const _Section({required this.title, required this.children}); + final String title; + final List children; + + @override + Widget build(BuildContext context) => Padding( + padding: const EdgeInsets.only(bottom: 16), + child: Card( + clipBehavior: Clip.antiAlias, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), + child: Text(title, style: Theme.of(context).textTheme.titleMedium), + ), + ...children, + ], + ), + ), + ); +} + +class _AccountButtons extends StatelessWidget { + const _AccountButtons({required this.connecting, required this.onConnect}); + final BusyProvider? connecting; + final ValueChanged onConnect; + + @override + Widget build(BuildContext context) => Padding( + padding: const EdgeInsets.all(12), + child: Wrap( + spacing: 8, + runSpacing: 8, + children: [ + _button(context, BusyProvider.google, context.l10n.addGoogleAccount), + _button( + context, + BusyProvider.microsoft, + context.l10n.addMicrosoftAccount, + ), + _button( + context, + BusyProvider.appleICloud, + context.l10n.addAppleICloudAccount, + ), + _button( + context, + BusyProvider.nextcloud, + context.l10n.addNextcloudAccount, + ), + ], + ), + ); + + Widget _button(BuildContext context, BusyProvider provider, String label) => + FutureBuilder( + future: switch (provider) { + BusyProvider.google => + BusyMaxAndroidPlatform.instance.googleAuthorizationAvailable(), + BusyProvider.microsoft => + BusyMaxAndroidPlatform.instance.microsoftAuthorizationAvailable(), + _ => Future.value(true), + }, + builder: (context, snapshot) { + final available = snapshot.data ?? false; + return OutlinedButton.icon( + onPressed: connecting == null && available + ? () => onConnect(provider) + : null, + icon: connecting == provider + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : Icon(_providerIcon(provider)), + label: Text(label), + ); + }, + ); +} + +class _NotificationSwitch extends StatelessWidget { + const _NotificationSwitch({ + required this.title, + required this.value, + required this.onChanged, + }); + final String title; + final bool value; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) => SwitchListTile( + secondary: const Icon(Icons.notifications_outlined), + title: Text(title), + value: value, + onChanged: onChanged, + ); +} + +IconData _providerIcon(BusyProvider provider) => switch (provider) { + BusyProvider.google => Icons.g_mobiledata, + BusyProvider.microsoft => Icons.window, + BusyProvider.appleICloud => Icons.cloud_outlined, + BusyProvider.nextcloud => Icons.cloud_sync_outlined, + BusyProvider.webCal => Icons.public, +}; + +Color _colorFromHex(String value) => + Color(0xff000000 | int.parse(value.replaceFirst('#', ''), radix: 16)); + +Future showAndroidIcsImport( + BuildContext context, + WidgetRef ref, { + AndroidDocument? document, +}) async { + try { + final selected = + document ?? + await BusyMaxAndroidPlatform.instance.openDocument( + maximumBytes: icalIngestionDecodedBodyLimit, + ); + if (selected == null || !context.mounted) return; + final service = ref.read(icalImportServiceProvider); + final preview = service.parsePreview(selected.bytes); + final destinations = await service.writableDestinations(); + if (!context.mounted) return; + CalendarSourceEntity? destination = destinations.firstOrNull; + var newCopies = false; + final selection = await showDialog( + context: context, + barrierDismissible: false, + builder: (dialogContext) => StatefulBuilder( + builder: (context, setDialogState) => AlertDialog( + title: Text(context.l10n.importIcsPreview), + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text(context.l10n.importEventsFound(preview.eventCount)), + if (preview.invalidEventCount > 0) + Text( + context.l10n.importInvalidEvents(preview.invalidEventCount), + ), + const SizedBox(height: 16), + if (destinations.isEmpty) + Text(context.l10n.noWritableCalendars) + else + DropdownButtonFormField( + initialValue: destination, + decoration: InputDecoration( + labelText: context.l10n.importDestinationCalendar, + ), + items: [ + for (final source in destinations) + DropdownMenuItem( + value: source, + child: Text(source.summary), + ), + ], + onChanged: (value) => + setDialogState(() => destination = value), + ), + if (destination?.provider == BusyProvider.nextcloud) + CheckboxListTile( + contentPadding: EdgeInsets.zero, + title: Text(context.l10n.nextcloudImportCopies), + value: newCopies, + onChanged: (value) => + setDialogState(() => newCopies = value == true), + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext), + child: Text(context.l10n.cancel), + ), + FilledButton( + onPressed: destination == null + ? null + : () => Navigator.pop( + dialogContext, + IcalImportSelection(destination!, newCopies: newCopies), + ), + child: Text(context.l10n.importIcsConfirm), + ), + ], + ), + ), + ); + if (selection == null || !context.mounted) return; + final report = await service.importPreview( + preview: preview, + destination: selection.destination, + nativeDuplicates: selection.duplicatePolicy, + normalizeNativeSchedulingMethod: + selection.destination.provider == BusyProvider.nextcloud, + ); + if (!context.mounted) return; + await showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: Text(context.l10n.importIcsComplete), + content: Text( + [ + context.l10n.importQueued(report.queued), + context.l10n.importDuplicatesSkipped(report.duplicatesSkipped), + context.l10n.importUnsupportedSets( + report.unsupportedRecurrenceSets.length, + ), + context.l10n.importInvalidEvents(report.invalidEvents), + ].join('\n'), + ), + actions: [ + FilledButton( + onPressed: () => Navigator.pop(dialogContext), + child: Text(context.l10n.close), + ), + ], + ), + ); + } on Object catch (error) { + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.importIcsFailed('$error'))), + ); + } +} diff --git a/lib/src/android/presentation/android_tasks_screen.dart b/lib/src/android/presentation/android_tasks_screen.dart new file mode 100644 index 00000000..22acaced --- /dev/null +++ b/lib/src/android/presentation/android_tasks_screen.dart @@ -0,0 +1,819 @@ +import 'dart:convert'; + +import 'package:drift/drift.dart' hide Column; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:intl/intl.dart'; + +import '../../app/app_bootstrap.dart'; +import '../../features/accounts/data/accounts_repository.dart'; +import '../../features/task_lists/data/task_lists_repository.dart'; +import '../../features/tasks/data/tasks_repository.dart'; +import '../../features/tasks/domain/task_capabilities.dart'; +import '../../features/tasks/presentation/task_details_draft.dart'; +import '../../features/recurrence/domain/event_recurrence_codec.dart'; +import '../../features/recurrence/domain/recurrence_rule.dart'; +import '../../l10n/l10n.dart'; +import '../../providers/busy_provider.dart'; +import '../../schedule/schedule_filters.dart'; +import '../../schedule/schedule_item.dart'; + +final _androidTasksProvider = FutureProvider.autoDispose + .family< + List, + ({ + String query, + String? accountId, + String? listId, + bool completed, + bool noDate, + }) + >((ref, key) { + ref.watch(scheduleDataRevisionProvider); + final lists = + ref.watch(scheduleTaskListsProvider).valueOrNull ?? const []; + final selected = lists + .where( + (list) => list.id == key.listId && list.accountId == key.accountId, + ) + .firstOrNull; + return ref + .watch(scheduleRepositoryProvider) + .listAllTasks( + filters: ScheduleFilters( + query: key.query, + showCompletedTasks: key.completed, + showNoDateTasks: key.noDate, + taskListFilterActive: selected != null, + taskListKeys: selected == null + ? const {} + : { + ScheduleTaskListKey( + accountId: selected.accountId, + taskListId: selected.id, + ), + }, + includeCalendarEvents: false, + ), + ); + }); + +class AndroidTasksScreen extends ConsumerStatefulWidget { + const AndroidTasksScreen({super.key}); + @override + ConsumerState createState() => _AndroidTasksScreenState(); +} + +class _AndroidTasksScreenState extends ConsumerState { + String _query = ''; + String? _accountId; + String? _listId; + bool _showCompleted = true; + bool _showNoDate = true; + + @override + Widget build(BuildContext context) { + final lists = ref.watch(scheduleTaskListsProvider).valueOrNull ?? const []; + if (_listId != null && + !lists.any( + (list) => list.id == _listId && list.accountId == _accountId, + )) { + _accountId = null; + _listId = null; + } + final tasks = ref.watch( + _androidTasksProvider(( + query: _query, + accountId: _accountId, + listId: _listId, + completed: _showCompleted, + noDate: _showNoDate, + )), + ); + return Scaffold( + appBar: AppBar( + title: Text(context.l10n.tasks), + actions: [ + PopupMenuButton( + icon: const Icon(Icons.filter_list), + onSelected: (value) => setState(() { + if (value == 'completed') _showCompleted = !_showCompleted; + if (value == 'no-date') _showNoDate = !_showNoDate; + }), + itemBuilder: (context) => [ + CheckedPopupMenuItem( + value: 'completed', + checked: _showCompleted, + child: Text(context.l10n.completed), + ), + CheckedPopupMenuItem( + value: 'no-date', + checked: _showNoDate, + child: Text(context.l10n.noDate), + ), + ], + ), + ], + bottom: PreferredSize( + preferredSize: const Size.fromHeight(116), + child: Padding( + padding: const EdgeInsets.fromLTRB(12, 0, 12, 12), + child: Column( + children: [ + SearchBar( + hintText: context.l10n.windowsSearch, + leading: const Icon(Icons.search), + onChanged: (value) => setState(() => _query = value), + ), + const SizedBox(height: 8), + DropdownButtonFormField( + isExpanded: true, + initialValue: _listId == null + ? null + : _taskListKey(_accountId!, _listId!), + decoration: InputDecoration( + labelText: context.l10n.list, + isDense: true, + ), + items: [ + DropdownMenuItem( + value: null, + child: Text(context.l10n.allTasks), + ), + for (final list in lists) + DropdownMenuItem( + value: _taskListKey(list.accountId, list.id), + child: Text(list.title), + ), + ], + onChanged: (value) => setState(() { + if (value == null) { + _accountId = null; + _listId = null; + return; + } + final separator = value.indexOf('\u0000'); + _accountId = value.substring(0, separator); + _listId = value.substring(separator + 1); + }), + ), + ], + ), + ), + ), + ), + body: tasks.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (error, _) => Center(child: Text('$error')), + data: (items) => items.isEmpty + ? Center(child: Text(context.l10n.noTasks)) + : ListView.separated( + padding: const EdgeInsets.fromLTRB(8, 8, 8, 96), + itemCount: items.length, + separatorBuilder: (_, _) => const Divider(height: 1), + itemBuilder: (context, index) { + final task = items[index]; + return ListTile( + contentPadding: EdgeInsets.only( + left: 8.0 + 16 * task.hierarchyDepth.clamp(0, 3), + right: 12, + ), + leading: IconButton( + iconSize: 30, + tooltip: task.completed + ? context.l10n.taskStatusCompleted + : context.l10n.taskStatusInProcess, + onPressed: task.capabilities.canEdit + ? () => _toggle(task) + : null, + icon: Icon( + task.completed + ? Icons.check_circle + : Icons.radio_button_unchecked, + ), + ), + title: Text( + task.title, + style: task.completed + ? const TextStyle( + decoration: TextDecoration.lineThrough, + ) + : null, + ), + subtitle: Text( + [ + task.sourceName ?? task.provider.displayName, + if (task.start != null) + DateFormat.yMMMd().format(task.start!), + if (task.parentTitle != null) task.parentTitle!, + ].join(' · '), + ), + trailing: task.hasSubtasks + ? const Icon(Icons.account_tree_outlined) + : null, + onTap: () => + showAndroidTaskEditor(context, ref, task: task), + ); + }, + ), + ), + floatingActionButton: FloatingActionButton( + onPressed: lists.isEmpty ? null : () => _create(lists), + tooltip: context.l10n.newTask, + child: const Icon(Icons.add_task), + ), + ); + } + + Future _toggle(TaskScheduleItem task) async { + try { + await ref + .read(tasksRepositoryForAccountProvider(task.accountId)) + .patchTask( + task.sourceId, + task.id, + TaskPatchInput({ + 'status': task.completed ? 'needsAction' : 'completed', + }), + ); + } on Object catch (error) { + if (mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('$error'))); + } + } + } + + Future _create(List lists) async { + final current = lists + .where((list) => list.id == _listId && list.accountId == _accountId) + .firstOrNull; + final selected = + current ?? + await selectAndroidTaskList( + context, + lists, + title: context.l10n.newTask, + ); + if (selected == null || !mounted) return; + final accounts = + ref.read(accountsStreamProvider).valueOrNull ?? const []; + final account = accounts + .where((value) => value.id == selected.accountId) + .firstOrNull; + if (account == null) return; + await showAndroidTaskEditor( + context, + ref, + creationList: selected, + creationProvider: account.provider, + ); + } +} + +Future selectAndroidTaskList( + BuildContext context, + List lists, { + required String title, +}) { + if (lists.length == 1) return Future.value(lists.single); + return showModalBottomSheet( + context: context, + showDragHandle: true, + builder: (sheetContext) => SafeArea( + child: ListView( + shrinkWrap: true, + children: [ + ListTile(title: Text(title)), + for (final list in lists) + ListTile( + leading: const Icon(Icons.checklist_outlined), + title: Text(list.title), + onTap: () => Navigator.pop(sheetContext, list), + ), + ], + ), + ), + ); +} + +Future showAndroidTaskEditor( + BuildContext context, + WidgetRef ref, { + TaskScheduleItem? task, + TaskListEntity? creationList, + BusyProvider? creationProvider, + DateTime? initialDue, +}) async { + TaskEntity? entity; + if (task != null) { + entity = await ref + .read(tasksRepositoryForAccountProvider(task.accountId)) + .watchTask(task.sourceId, task.id) + .first; + if (!context.mounted || entity == null) return; + } + await Navigator.of(context).push( + MaterialPageRoute( + fullscreenDialog: true, + builder: (_) => AndroidTaskEditor( + accountId: task?.accountId ?? creationList!.accountId, + provider: task?.provider ?? creationProvider!, + task: entity, + creationList: creationList, + initialDue: initialDue, + ), + ), + ); +} + +class AndroidTaskEditor extends ConsumerStatefulWidget { + const AndroidTaskEditor({ + super.key, + required this.accountId, + required this.provider, + this.task, + this.creationList, + this.initialDue, + }); + final String accountId; + final BusyProvider provider; + final TaskEntity? task; + final TaskListEntity? creationList; + final DateTime? initialDue; + @override + ConsumerState createState() => _AndroidTaskEditorState(); +} + +class _AndroidTaskEditorState extends ConsumerState { + late TaskDetailsDraft _draft = widget.task == null + ? TaskDetailsDraft.forCreation( + taskListId: widget.creationList!.id, + provider: widget.provider, + timeZone: ref.read(localTimeZoneProvider), + due: widget.initialDue, + ) + : TaskDetailsDraft.fromTask( + widget.task!, + ref.read(localTimeZoneProvider), + ); + late final TextEditingController _title = TextEditingController( + text: _draft.title, + ); + late final TextEditingController _notes = TextEditingController( + text: _draft.notes, + ); + late final TextEditingController _location = TextEditingController( + text: _draft.location, + ); + late final TextEditingController _categories = TextEditingController( + text: _draft.categories.join(', '), + ); + bool _saving = false; + late RecurrenceFrequency _recurrenceFrequency = _taskRecurrence().frequency; + + TaskCollectionCapabilities get _capabilities => switch (widget.provider) { + BusyProvider.appleICloud || BusyProvider.nextcloud => + ref + .read( + davTaskCollectionCapabilitiesProvider(( + accountId: widget.accountId, + taskListId: _draft.taskListId, + )), + ) + .valueOrNull ?? + noTaskCollectionCapabilities, + BusyProvider.google || + BusyProvider.microsoft => adapterDefaultTaskCapabilities(widget.provider), + BusyProvider.webCal => noTaskCollectionCapabilities, + }; + + @override + void dispose() { + _title.dispose(); + _notes.dispose(); + _location.dispose(); + _categories.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + if (widget.provider == BusyProvider.appleICloud || + widget.provider == BusyProvider.nextcloud) { + ref.watch( + davTaskCollectionCapabilitiesProvider(( + accountId: widget.accountId, + taskListId: _draft.taskListId, + )), + ); + } + final capabilities = _capabilities; + return Scaffold( + appBar: AppBar( + leading: const CloseButton(), + title: Text( + widget.task == null ? context.l10n.newTask : context.l10n.editTask, + ), + actions: [ + TextButton( + onPressed: _saving ? null : _save, + child: Text(context.l10n.save), + ), + ], + ), + body: ListView( + padding: const EdgeInsets.all(16), + children: [ + TextField( + controller: _title, + autofocus: widget.task == null, + decoration: InputDecoration(labelText: context.l10n.title), + onChanged: (v) => _draft = _draft.copyWith(title: v), + ), + const SizedBox(height: 12), + TextField( + controller: _notes, + minLines: 3, + maxLines: 8, + decoration: InputDecoration(labelText: context.l10n.notes), + onChanged: (v) => _draft = _draft.copyWith(notes: v), + ), + if (capabilities.supportsDueDate) + ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.event), + title: Text(context.l10n.dueDate), + subtitle: Text(_draft.dueDate ?? context.l10n.noDate), + trailing: _draft.dueDate == null + ? null + : IconButton( + icon: const Icon(Icons.clear), + onPressed: () => setState( + () => _draft = _draft.copyWith( + dueDate: null, + microsoftDueTime: null, + ), + ), + ), + onTap: _pickDue, + ), + if (capabilities.supportsDueTime && _draft.dueDate != null) + ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.schedule), + title: Text(context.l10n.endTime), + subtitle: Text(_draft.microsoftDueTime ?? context.l10n.allDay), + onTap: _pickDueTime, + ), + if (capabilities.supportsReminderDateTime) + SwitchListTile( + contentPadding: EdgeInsets.zero, + title: Text(context.l10n.reminder), + value: _draft.microsoftReminderEnabled, + onChanged: (value) => setState( + () => _draft = _draft.copyWith( + microsoftReminderEnabled: value, + microsoftReminderDate: value + ? (_draft.dueDate ?? _today()) + : null, + microsoftReminderTime: value + ? (_draft.microsoftDueTime ?? '09:00') + : null, + ), + ), + ), + if (capabilities.supportsImportance) + DropdownButtonFormField( + initialValue: _draft.importance, + decoration: InputDecoration(labelText: context.l10n.importance), + items: [ + DropdownMenuItem( + value: 'low', + child: Text(context.l10n.importanceLow), + ), + DropdownMenuItem( + value: 'normal', + child: Text(context.l10n.importanceNormal), + ), + DropdownMenuItem( + value: 'high', + child: Text(context.l10n.importanceHigh), + ), + ], + onChanged: (v) => + setState(() => _draft = _draft.copyWith(importance: v)), + ), + if (capabilities.supportsRecurrence) ...[ + const SizedBox(height: 12), + DropdownButtonFormField( + initialValue: _recurrenceFrequency, + decoration: InputDecoration(labelText: context.l10n.repeat), + items: [ + DropdownMenuItem( + value: RecurrenceFrequency.none, + child: Text(context.l10n.repeatNone), + ), + DropdownMenuItem( + value: RecurrenceFrequency.daily, + child: Text(context.l10n.repeatDaily), + ), + DropdownMenuItem( + value: RecurrenceFrequency.weekly, + child: Text(context.l10n.repeatWeekly), + ), + DropdownMenuItem( + value: RecurrenceFrequency.monthly, + child: Text(context.l10n.repeatMonthly), + ), + DropdownMenuItem( + value: RecurrenceFrequency.yearly, + child: Text(context.l10n.repeatYearly), + ), + ], + onChanged: _setRecurrence, + ), + ], + if (capabilities.supportsCategories) ...[ + const SizedBox(height: 12), + TextField( + controller: _categories, + decoration: InputDecoration(labelText: context.l10n.categories), + onChanged: (v) => _draft = _draft.copyWith( + categories: v + .split(',') + .map((e) => e.trim()) + .where((e) => e.isNotEmpty) + .toList(), + ), + ), + ], + if (capabilities.supportsLocation) ...[ + const SizedBox(height: 12), + TextField( + controller: _location, + decoration: InputDecoration(labelText: context.l10n.location), + onChanged: (v) => _draft = _draft.copyWith(location: v), + ), + ], + if (capabilities.supportsPercentComplete) + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(context.l10n.completionPercent(_draft.percentComplete)), + Slider( + value: _draft.percentComplete.toDouble(), + max: 100, + divisions: 20, + onChanged: (v) => setState( + () => _draft = _draft.copyWith(percentComplete: v.round()), + ), + ), + ], + ), + if (widget.task != null) ...[ + const SizedBox(height: 24), + OutlinedButton.icon( + onPressed: _saving ? null : _delete, + icon: const Icon(Icons.delete_outline), + label: Text(context.l10n.deleteTask), + ), + ], + const SizedBox(height: 40), + ], + ), + ); + } + + Future _pickDue() async { + final current = DateTime.tryParse(_draft.dueDate ?? '') ?? DateTime.now(); + final value = await showDatePicker( + context: context, + initialDate: current, + firstDate: DateTime(1970), + lastDate: DateTime(2200), + ); + if (value != null) { + setState(() => _draft = _draft.copyWith(dueDate: _date(value))); + } + } + + Future _pickDueTime() async { + final parts = (_draft.microsoftDueTime ?? '09:00').split(':'); + final value = await showTimePicker( + context: context, + initialTime: TimeOfDay( + hour: int.parse(parts[0]), + minute: int.parse(parts[1]), + ), + ); + if (value != null) { + setState( + () => _draft = _draft.copyWith( + microsoftDueTime: + '${value.hour.toString().padLeft(2, '0')}:${value.minute.toString().padLeft(2, '0')}', + ), + ); + } + } + + Future _save() async { + _draft = _draft.copyWith( + title: _title.text.trim(), + notes: _notes.text, + location: _location.text, + categories: _categories.text + .split(',') + .map((e) => e.trim()) + .where((e) => e.isNotEmpty) + .toList(), + ); + if (_draft.title.trim().isEmpty || + !_draft.hasValidTaskUrlFor(_capabilities) || + _draft.scheduleIssueFor(_capabilities) != TaskScheduleIssue.none) { + return; + } + setState(() => _saving = true); + try { + final repository = ref.read( + tasksRepositoryForAccountProvider(widget.accountId), + ); + if (widget.task == null) { + await repository.createTask( + _draft.taskListId, + _draft.toCreateInput( + _capabilities, + localTimeZone: ref.read(localTimeZoneProvider), + ), + ); + } else { + await repository.patchTask( + _draft.taskListId, + _draft.taskId, + TaskPatchInput( + _draft.toPatch( + widget.task!, + _capabilities, + localTimeZone: ref.read(localTimeZoneProvider), + ), + ), + ); + } + if (mounted) Navigator.pop(context); + } on Object catch (error) { + if (mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('$error'))); + } + } finally { + if (mounted) setState(() => _saving = false); + } + } + + RecurrenceRule _taskRecurrence() { + final creation = _draft.creationRecurrence; + if (creation != null) return creation.rule; + final raw = _draft.recurrenceJson; + if (raw == null) return const RecurrenceRule.none(); + try { + return EventRecurrenceCodec.decode( + widget.provider, + jsonDecode(raw), + baseDate: DateTime.tryParse(_draft.dueDate ?? ''), + ); + } on Object { + return const RecurrenceRule.none(); + } + } + + void _setRecurrence(RecurrenceFrequency? frequency) { + if (frequency == null) return; + final base = DateTime.tryParse(_draft.dueDate ?? '') ?? DateTime.now(); + final rule = _simpleTaskRecurrenceRule(frequency, base); + setState(() { + _recurrenceFrequency = frequency; + if (widget.task == null) { + _draft = _draft.copyWith( + creationRecurrence: (provider: widget.provider, rule: rule), + ); + } else { + final encoded = rule.repeats + ? EventRecurrenceCodec.encode( + widget.provider, + rule, + baseDate: base, + allDay: _draft.microsoftDueTime == null, + timeZone: _draft.microsoftDueTimeZone, + original: _draft.recurrenceJson == null + ? null + : jsonDecode(_draft.recurrenceJson!), + ) + : null; + _draft = _draft.copyWith( + recurrenceJson: encoded == null ? null : jsonEncode(encoded), + ); + } + }); + } + + Future _delete() async { + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(context.l10n.deleteTask), + content: Text(context.l10n.deleteTaskConfirmation(widget.task!.title)), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: Text(context.l10n.cancel), + ), + FilledButton( + onPressed: () => Navigator.pop(context, true), + child: Text(context.l10n.delete), + ), + ], + ), + ); + if (confirmed != true) return; + setState(() => _saving = true); + try { + await ref + .read(tasksRepositoryForAccountProvider(widget.accountId)) + .deleteTask(widget.task!.taskListId, widget.task!.id); + if (mounted) Navigator.pop(context); + } on Object catch (error) { + if (mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('$error'))); + } + } finally { + if (mounted) setState(() => _saving = false); + } + } +} + +String _taskListKey(String accountId, String listId) => + '$accountId\u0000$listId'; + +Future showAndroidTaskById( + BuildContext context, + WidgetRef ref, { + required String accountId, + required String taskId, +}) async { + final database = ref.read(databaseProvider); + final row = + await (database.select(database.tasks)..where( + (task) => + task.accountId.equals(accountId) & + task.id.equals(taskId) & + task.pendingDelete.equals(false), + )) + .getSingleOrNull(); + final account = await ref + .read(accountsRepositoryProvider) + .accountById(accountId); + if (!context.mounted || row == null || account == null) return; + await Navigator.of(context).push( + MaterialPageRoute( + fullscreenDialog: true, + builder: (_) => AndroidTaskEditor( + accountId: accountId, + provider: account.provider, + task: TaskEntity.fromRow(row), + ), + ), + ); +} + +RecurrenceRule _simpleTaskRecurrenceRule( + RecurrenceFrequency frequency, + DateTime base, +) { + final weekday = const [ + 'MO', + 'TU', + 'WE', + 'TH', + 'FR', + 'SA', + 'SU', + ][base.weekday - 1]; + return const RecurrenceRule.none().copyWith( + frequency: frequency, + byDay: frequency == RecurrenceFrequency.weekly ? [weekday] : const [], + byMonthDay: + frequency == RecurrenceFrequency.monthly || + frequency == RecurrenceFrequency.yearly + ? [base.day] + : const [], + byMonth: frequency == RecurrenceFrequency.yearly ? [base.month] : const [], + ); +} + +String _date(DateTime value) => + '${value.year.toString().padLeft(4, '0')}-${value.month.toString().padLeft(2, '0')}-${value.day.toString().padLeft(2, '0')}'; +String _today() => _date(DateTime.now()); diff --git a/lib/src/app/app_bootstrap.dart b/lib/src/app/app_bootstrap.dart index 3838ac32..bca10962 100644 --- a/lib/src/app/app_bootstrap.dart +++ b/lib/src/app/app_bootstrap.dart @@ -10,6 +10,7 @@ import 'package:busymax/src/providers/busy_provider.dart'; import 'package:busymax/src/features/tasks/domain/task_capabilities.dart'; import '../core/time/provider_date_time.dart'; +import '../core/auth/account_token_broker.dart'; import '../core/time/stored_temporal_projection.dart'; import '../dav/nextcloud/nextcloud_native_export.dart'; import '../config/build_config.dart'; @@ -44,6 +45,7 @@ import '../features/notifications/notification_cancellation_queue.dart'; import '../features/notifications/due_today_notification_scheduler.dart'; import '../features/notifications/notification_schedule_service.dart'; import '../features/notifications/notification_scheduler.dart'; +import '../features/notifications/notification_reconciler.dart'; import '../features/sync/account_sync_operations.dart'; import '../features/sync/all_accounts_sync_scheduler.dart'; import '../features/sync/calendar_sync_engine.dart'; @@ -234,10 +236,17 @@ final microsoftOAuthServiceProvider = Provider((ref) { }); final applicationMicrosoftOAuthServiceProvider = - Provider( + Provider( (ref) => ref.watch(microsoftOAuthServiceProvider), ); +final accountTokenBrokerProvider = Provider((ref) { + return DesktopAccountTokenBroker( + google: ref.watch(applicationOAuthServiceProvider), + microsoft: ref.watch(microsoftOAuthServiceProvider), + ); +}); + final authenticatedHttpClientProvider = Provider((ref) { return AuthenticatedHttpClient( inner: ref.watch(retryingHttpClientProvider), @@ -276,6 +285,15 @@ final desktopNotificationServiceProvider = Provider( }, ); +final operationalNotificationReporterProvider = + Provider((ref) { + final desktop = ref.watch(desktopNotificationServiceProvider); + return CallbackOperationalNotificationReporter( + onSyncFailure: desktop.notifySyncFailure, + onConflict: desktop.notifyConflict, + ); + }); + final desktopWindowServiceProvider = Provider( (ref) => const NoOpDesktopWindowService(), ); @@ -376,7 +394,7 @@ final davSettingsRepositoryProvider = Provider((ref) { return DavSettingsRepository( database: ref.watch(databaseProvider), onVisibilityChanged: (_) => - ref.read(notificationSchedulerProvider).checkNow(), + ref.read(notificationReconcilerProvider).reconcile(), ); }); @@ -452,7 +470,14 @@ final localTimeZoneSourceProvider = Provider( (ref) => const FixedLocalTimeZoneSource('Etc/UTC'), ); -final localTimeZoneProvider = Provider((ref) => 'Etc/UTC'); +final androidLocalTimeZoneStateProvider = StateProvider( + (ref) => 'Etc/UTC', +); +final androidSystemUses24HourProvider = StateProvider((ref) => false); + +final localTimeZoneProvider = Provider( + (ref) => ref.watch(androidLocalTimeZoneStateProvider), +); final googleTasksApiClientForAccountProvider = Provider.family((ref, accountId) { @@ -461,11 +486,11 @@ final googleTasksApiClientForAccountProvider = httpClient: ref.watch(retryingHttpClientProvider), baseUri: Uri.parse(config.googleApiBaseUrl), authorizationHeaderProvider: () => ref - .read(applicationOAuthServiceProvider) - .authorizationHeaderForAccount(accountId), + .read(accountTokenBrokerProvider) + .authorizationHeader(BusyProvider.google, accountId), unauthorizedRefreshProvider: () => ref - .read(applicationOAuthServiceProvider) - .refreshTokenForAccount(accountId), + .read(accountTokenBrokerProvider) + .recoverUnauthorized(BusyProvider.google, accountId), ); }); @@ -476,11 +501,11 @@ final googleCalendarApiClientForAccountProvider = httpClient: ref.watch(retryingHttpClientProvider), baseUri: Uri.parse(config.googleApiBaseUrl), authorizationHeaderProvider: () => ref - .read(applicationOAuthServiceProvider) - .authorizationHeaderForAccount(accountId), + .read(accountTokenBrokerProvider) + .authorizationHeader(BusyProvider.google, accountId), unauthorizedRefreshProvider: () => ref - .read(applicationOAuthServiceProvider) - .refreshTokenForAccount(accountId), + .read(accountTokenBrokerProvider) + .recoverUnauthorized(BusyProvider.google, accountId), ); }); @@ -491,11 +516,11 @@ final microsoftTodoApiClientForAccountProvider = httpClient: ref.watch(retryingHttpClientProvider), baseUri: Uri.parse(config.microsoftGraphBaseUrl), authorizationHeaderProvider: () => ref - .read(microsoftOAuthServiceProvider) - .authorizationHeaderForAccount(accountId), + .read(accountTokenBrokerProvider) + .authorizationHeader(BusyProvider.microsoft, accountId), unauthorizedRefreshProvider: () => ref - .read(microsoftOAuthServiceProvider) - .refreshTokenForAccount(accountId), + .read(accountTokenBrokerProvider) + .recoverUnauthorized(BusyProvider.microsoft, accountId), ); }); @@ -507,11 +532,11 @@ final microsoftCalendarApiClientForAccountProvider = baseUri: Uri.parse(config.microsoftGraphBaseUrl), responseTimeZone: ref.watch(localTimeZoneProvider), authorizationHeaderProvider: () => ref - .read(microsoftOAuthServiceProvider) - .authorizationHeaderForAccount(accountId), + .read(accountTokenBrokerProvider) + .authorizationHeader(BusyProvider.microsoft, accountId), unauthorizedRefreshProvider: () => ref - .read(microsoftOAuthServiceProvider) - .refreshTokenForAccount(accountId), + .read(accountTokenBrokerProvider) + .recoverUnauthorized(BusyProvider.microsoft, accountId), ); }); @@ -609,7 +634,7 @@ final syncEngineForAccountFactoryProvider = accountId: accountId, ), onConflictBlocked: ref - .read(desktopNotificationServiceProvider) + .read(operationalNotificationReporterProvider) .notifyConflict, ); }; @@ -649,10 +674,10 @@ final calendarSyncEngineForAccountFactoryProvider = accountId: accountId, ), onConflictBlocked: ref - .read(desktopNotificationServiceProvider) + .read(operationalNotificationReporterProvider) .notifyConflict, onNotificationScheduleChanged: () => - ref.read(notificationSchedulerProvider).checkNow(), + ref.read(notificationReconcilerProvider).reconcile(), ); }; }); @@ -671,10 +696,10 @@ final davAccountSyncEngineFactoryProvider = await NotificationScheduleService( database: ref.read(databaseProvider), ).rebuildUpcomingNotifications(accountId); - await ref.read(notificationSchedulerProvider).checkNow(); + await ref.read(notificationReconcilerProvider).reconcile(); }, reportPendingMutationFailure: (accountId, error) => ref - .read(desktopNotificationServiceProvider) + .read(operationalNotificationReporterProvider) .notifySyncFailure(error), ); }); @@ -690,6 +715,10 @@ final accountSyncCoordinatorProvider = Provider((ref) { return coordinator; }); +final crossEngineAccountGateProvider = Provider( + (ref) => const InProcessAccountGate(), +); + final accountSyncOperationsProvider = Provider((ref) { final accountsRepository = ref.watch(accountsRepositoryProvider); final connectivity = ref.watch(networkConnectivityMonitorProvider); @@ -740,11 +769,14 @@ final accountSyncOperationsProvider = Provider((ref) { } }, ); - return CoordinatedAccountSyncOperations( - coordinator: syncCoordinator, - inner: ConnectivityAwareAccountSyncOperations( - inner: routing, - requireNetwork: connectivity.requireNetwork, + return CrossEngineCoordinatedAccountSyncOperations( + gate: ref.watch(crossEngineAccountGateProvider), + inner: CoordinatedAccountSyncOperations( + coordinator: syncCoordinator, + inner: ConnectivityAwareAccountSyncOperations( + inner: routing, + requireNetwork: connectivity.requireNetwork, + ), ), ); }); @@ -782,7 +814,7 @@ final allAccountsSyncRunnerProvider = Provider((ref) { .listSyncEligibleAccounts, syncAccount: syncAccount, onSyncFailure: ref - .read(desktopNotificationServiceProvider) + .read(operationalNotificationReporterProvider) .notifySyncFailure, onAccountSyncFailure: (accountId, error) => _markAccountReconnectRequiredForSyncError(ref, accountId, error), @@ -808,7 +840,7 @@ final calendarRepositoryProvider = Provider((ref) { nextcloudCollections: (accountId) => ref.read(nextcloudCollectionServiceProvider(accountId)), onNotificationScheduleChanged: () => - ref.read(notificationSchedulerProvider).checkNow(), + ref.read(notificationReconcilerProvider).reconcile(), ); }); @@ -834,7 +866,7 @@ final webCalSubscriptionServiceProvider = Provider(( secretStore: ref.watch(secretStoreProvider), httpTransport: ref.watch(webCalHttpTransportProvider), onNotificationScheduleChanged: () => - ref.read(notificationSchedulerProvider).checkNow(), + ref.read(notificationReconcilerProvider).reconcile(), ); }); @@ -852,7 +884,7 @@ final icalImportServiceProvider = Provider((ref) { await NotificationScheduleService( database: ref.read(databaseProvider), ).rebuildUpcomingNotifications(accountId); - await ref.read(notificationSchedulerProvider).checkNow(); + await ref.read(notificationReconcilerProvider).reconcile(); } finally { ref .read( @@ -1034,7 +1066,7 @@ final taskListsRepositoryProvider = Provider((ref) { ), onMutationQueued: ref.watch(pendingMutationSyncRequesterProvider)?.request, onNotificationScheduleChanged: () => - ref.read(notificationSchedulerProvider).checkNow(), + ref.read(notificationReconcilerProvider).reconcile(), ); }); @@ -1051,7 +1083,7 @@ final taskListsRepositoryForAccountProvider = .watch(pendingMutationSyncRequesterForAccountProvider(accountId)) .request, onNotificationScheduleChanged: () => - ref.read(notificationSchedulerProvider).checkNow(), + ref.read(notificationReconcilerProvider).reconcile(), ); }); @@ -1066,7 +1098,7 @@ final tasksRepositoryProvider = Provider((ref) { apiClient: ref.watch(googleTasksApiClientProvider), onMutationQueued: ref.watch(pendingMutationSyncRequesterProvider)?.request, onNotificationScheduleChanged: () => - ref.read(notificationSchedulerProvider).checkNow(), + ref.read(notificationReconcilerProvider).reconcile(), ); }); @@ -1080,7 +1112,7 @@ final tasksRepositoryForAccountProvider = .watch(pendingMutationSyncRequesterForAccountProvider(accountId)) .request, onNotificationScheduleChanged: () => - ref.read(notificationSchedulerProvider).checkNow(), + ref.read(notificationReconcilerProvider).reconcile(), ); }); @@ -1096,7 +1128,7 @@ final pendingMutationSyncRequesterProvider = .read(accountSyncOperationsProvider) .syncTasks(accountId, full: false), onSyncFailure: ref - .watch(desktopNotificationServiceProvider) + .watch(operationalNotificationReporterProvider) .notifySyncFailure, onSyncError: (error) => _markAccountReconnectRequiredForSyncError(ref, accountId, error), @@ -1113,7 +1145,7 @@ final pendingMutationSyncRequesterForAccountProvider = .read(accountSyncOperationsProvider) .syncTasks(accountId, full: false), onSyncFailure: ref - .watch(desktopNotificationServiceProvider) + .watch(operationalNotificationReporterProvider) .notifySyncFailure, onSyncError: (error) => _markAccountReconnectRequiredForSyncError(ref, accountId, error), @@ -1130,7 +1162,7 @@ final pendingCalendarMutationSyncRequesterForAccountProvider = .read(accountSyncOperationsProvider) .syncCalendar(accountId, full: false), onSyncFailure: ref - .watch(desktopNotificationServiceProvider) + .watch(operationalNotificationReporterProvider) .notifySyncFailure, onSyncError: (error) => _markAccountReconnectRequiredForSyncError(ref, accountId, error), @@ -1156,7 +1188,7 @@ final pendingOpResolutionServiceForAccountProvider = .read(accountSyncOperationsProvider) .syncCalendar(accountId, full: false), onNotificationScheduleChanged: () => - ref.read(notificationSchedulerProvider).checkNow(), + ref.read(notificationReconcilerProvider).reconcile(), ); }); @@ -1182,7 +1214,7 @@ final syncSchedulerProvider = Provider((ref) { .listSyncEligibleAccounts, syncAccount: syncAccount, onSyncFailure: ref - .watch(desktopNotificationServiceProvider) + .watch(operationalNotificationReporterProvider) .notifySyncFailure, onAccountSyncFailure: (accountId, error) => _markAccountReconnectRequiredForSyncError(ref, accountId, error), @@ -1276,6 +1308,12 @@ final Provider notificationSchedulerProvider = return scheduler; }); +final notificationReconcilerProvider = Provider( + (ref) => CallbackNotificationReconciler( + ref.watch(notificationSchedulerProvider).checkNow, + ), +); + final _notificationOpenSequenceProvider = StateProvider((ref) => 0); Future _openNotificationSource( diff --git a/lib/src/app/app_settings.dart b/lib/src/app/app_settings.dart index 78d959c3..1f4c50df 100644 --- a/lib/src/app/app_settings.dart +++ b/lib/src/app/app_settings.dart @@ -60,6 +60,7 @@ class AppSettings { required this.lastDueTodayNotificationDate, required this.taskListScheduleVisibility, required this.scheduleViewMode, + this.androidScheduleViewMode, required this.scheduleDayStartMinute, required this.scheduleDayEndMinute, this.sidebarOrder = const ScheduleSidebarOrder.empty(), @@ -195,6 +196,10 @@ class AppSettings { json['scheduleViewMode'], defaults.scheduleViewMode, ), + androidScheduleViewMode: _enumFromNameOrNull( + ScheduleViewMode.values, + json['androidScheduleViewMode'], + ), scheduleDayStartMinute: scheduleDayStartMinute, scheduleDayEndMinute: scheduleDayEndMinute, ); @@ -222,6 +227,7 @@ class AppSettings { final Map taskListScheduleVisibility; final ScheduleSidebarOrder sidebarOrder; final ScheduleViewMode scheduleViewMode; + final ScheduleViewMode? androidScheduleViewMode; final int scheduleDayStartMinute; final int scheduleDayEndMinute; @@ -253,6 +259,7 @@ class AppSettings { 'taskListScheduleVisibility': taskListScheduleVisibility, 'sidebarOrder': sidebarOrder.toJson(), 'scheduleViewMode': scheduleViewMode.name, + 'androidScheduleViewMode': androidScheduleViewMode?.name, 'scheduleDayStartMinute': scheduleDayStartMinute, 'scheduleDayEndMinute': scheduleDayEndMinute, }; @@ -281,6 +288,7 @@ class AppSettings { Map? taskListScheduleVisibility, ScheduleSidebarOrder? sidebarOrder, ScheduleViewMode? scheduleViewMode, + Object? androidScheduleViewMode = _unset, int? scheduleDayStartMinute, int? scheduleDayEndMinute, bool clearLastDueTodayNotificationDate = false, @@ -325,6 +333,9 @@ class AppSettings { taskListScheduleVisibility ?? this.taskListScheduleVisibility, sidebarOrder: sidebarOrder ?? this.sidebarOrder, scheduleViewMode: scheduleViewMode ?? this.scheduleViewMode, + androidScheduleViewMode: identical(androidScheduleViewMode, _unset) + ? this.androidScheduleViewMode + : androidScheduleViewMode as ScheduleViewMode?, scheduleDayStartMinute: resolvedScheduleDayStartMinute, scheduleDayEndMinute: resolvedScheduleDayEndMinute, ); @@ -364,7 +375,9 @@ class JsonFileLocalSettingsStore implements LocalSettingsStore { Future save(Map json) async { final file = await _settingsFile(); await file.parent.create(recursive: true); - await file.writeAsString(jsonEncode(json)); + final temporary = File('${file.path}.tmp'); + await temporary.writeAsString(jsonEncode(json), flush: true); + await temporary.rename(file.path); } Future _settingsFile() async { @@ -474,6 +487,12 @@ class AppSettingsController extends StateNotifier { return _mutate((current) => current.copyWith(scheduleViewMode: mode)); } + Future setAndroidScheduleViewMode(ScheduleViewMode mode) { + return _mutate( + (current) => current.copyWith(androidScheduleViewMode: mode), + ); + } + Future setScheduleDayStartMinute(int minute) { return _mutate((current) { final start = _minuteOfDay(minute, current.scheduleDayStartMinute); diff --git a/lib/src/config/build_config.dart b/lib/src/config/build_config.dart index 616470e0..edfe9012 100644 --- a/lib/src/config/build_config.dart +++ b/lib/src/config/build_config.dart @@ -17,6 +17,7 @@ class BuildConfig { this.windowsAppUserModelId = '', this.windowsPackageVersion = '', this.windowsStoreMode = false, + this.androidNativeAuthorization = false, String? apiBaseUrl, required this.oauthAuthorizationEndpoint, required this.oauthTokenEndpoint, @@ -98,6 +99,28 @@ class BuildConfig { ); } + /// Android uses Google Identity Services and MSAL rather than embedded + /// desktop OAuth client secrets or loopback redirect listeners. + factory BuildConfig.forAndroid() { + final environment = BuildConfig.fromEnvironment(); + return BuildConfig( + googleOAuthClientId: environment.googleOAuthClientId, + googleOAuthClientSecret: '', + microsoftOAuthClientId: environment.microsoftOAuthClientId, + microsoftOAuthAuthorityTenant: environment.microsoftOAuthAuthorityTenant, + microsoftGraphBaseUrl: environment.microsoftGraphBaseUrl, + googleApiBaseUrl: environment.googleApiBaseUrl, + feedbackEndpoint: environment.feedbackEndpoint, + privacyPolicyUrl: environment.privacyPolicyUrl, + supportUrl: environment.supportUrl, + homepageUrl: environment.homepageUrl, + oauthAuthorizationEndpoint: environment.oauthAuthorizationEndpoint, + oauthTokenEndpoint: environment.oauthTokenEndpoint, + oauthRevocationEndpoint: environment.oauthRevocationEndpoint, + androidNativeAuthorization: true, + ); + } + final String googleOAuthClientId; final String googleOAuthClientSecret; final String microsoftOAuthClientId; @@ -111,6 +134,7 @@ class BuildConfig { final String windowsAppUserModelId; final String windowsPackageVersion; final bool windowsStoreMode; + final bool androidNativeAuthorization; final String apiBaseUrl; final String oauthAuthorizationEndpoint; final String oauthTokenEndpoint; @@ -119,9 +143,12 @@ class BuildConfig { final BusyMaxDemoTheme demoTheme; bool get hasGoogleOAuthClientId => - useFakeProviderData || googleOAuthClientId.trim().isNotEmpty; + useFakeProviderData || + androidNativeAuthorization || + googleOAuthClientId.trim().isNotEmpty; bool get hasMicrosoftOAuthClientId => - !useFakeProviderData && microsoftOAuthClientId.trim().isNotEmpty; + !useFakeProviderData && + (androidNativeAuthorization || microsoftOAuthClientId.trim().isNotEmpty); bool get hasAppleICloudProvider => !useFakeProviderData; bool get hasNextcloudProvider => !useFakeProviderData; bool get hasAnyProviderConfigured => diff --git a/lib/src/core/auth/account_token_broker.dart b/lib/src/core/auth/account_token_broker.dart new file mode 100644 index 00000000..129e3625 --- /dev/null +++ b/lib/src/core/auth/account_token_broker.dart @@ -0,0 +1,49 @@ +import '../../google_tasks/oauth/oauth_service.dart'; +import '../../microsoft_todo/oauth/microsoft_oauth_service.dart'; +import '../../providers/busy_provider.dart'; + +/// Provider-neutral token access used by API clients. +abstract interface class AccountTokenBroker { + Future authorizationHeader(BusyProvider provider, String accountId); + + Future recoverUnauthorized(BusyProvider provider, String accountId); +} + +final class DesktopAccountTokenBroker implements AccountTokenBroker { + const DesktopAccountTokenBroker({ + required this.google, + required this.microsoft, + }); + + final OAuthService google; + final MicrosoftOAuthService microsoft; + + @override + Future authorizationHeader(BusyProvider provider, String accountId) => + switch (provider) { + BusyProvider.google => google.authorizationHeaderForAccount(accountId), + BusyProvider.microsoft => microsoft.authorizationHeaderForAccount( + accountId, + ), + _ => throw StateError( + '$provider does not use OAuth API authorization.', + ), + }; + + @override + Future recoverUnauthorized( + BusyProvider provider, + String accountId, + ) async { + switch (provider) { + case BusyProvider.google: + await google.refreshTokenForAccount(accountId); + case BusyProvider.microsoft: + await microsoft.refreshTokenForAccount(accountId); + case BusyProvider.appleICloud: + case BusyProvider.nextcloud: + case BusyProvider.webCal: + throw StateError('$provider does not use OAuth API authorization.'); + } + } +} diff --git a/lib/src/db/app_database.dart b/lib/src/db/app_database.dart index 987f2c6b..c1edd9db 100644 --- a/lib/src/db/app_database.dart +++ b/lib/src/db/app_database.dart @@ -36,6 +36,8 @@ part 'daos/tasks_dao.dart'; CalendarColors, ScheduleItemOverrides, NotificationSchedule, + AndroidNotificationMappings, + AndroidDailySummarySchedules, LocationResolutions, ], daos: [TaskListsDao, TasksDao, PendingOpsDao, SyncRunsDao], diff --git a/lib/src/db/app_database.g.dart b/lib/src/db/app_database.g.dart index d77ac46b..7aaab638 100644 --- a/lib/src/db/app_database.g.dart +++ b/lib/src/db/app_database.g.dart @@ -25163,563 +25163,408 @@ class NotificationScheduleCompanion } } -class $LocationResolutionsTable extends LocationResolutions - with TableInfo<$LocationResolutionsTable, LocationResolution> { +class $AndroidNotificationMappingsTable extends AndroidNotificationMappings + with + TableInfo< + $AndroidNotificationMappingsTable, + AndroidNotificationMapping + > { @override final GeneratedDatabase attachedDatabase; final String? _alias; - $LocationResolutionsTable(this.attachedDatabase, [this._alias]); - static const VerificationMeta _kindMeta = const VerificationMeta('kind'); - @override - late final GeneratedColumn kind = GeneratedColumn( - 'kind', - aliasedName, - false, - type: DriftSqlType.string, - requiredDuringInsert: true, - ); - static const VerificationMeta _accountIdMeta = const VerificationMeta( - 'accountId', + $AndroidNotificationMappingsTable(this.attachedDatabase, [this._alias]); + static const VerificationMeta _scheduleIdMeta = const VerificationMeta( + 'scheduleId', ); @override - late final GeneratedColumn accountId = GeneratedColumn( - 'account_id', + late final GeneratedColumn scheduleId = GeneratedColumn( + 'schedule_id', aliasedName, false, type: DriftSqlType.string, requiredDuringInsert: true, defaultConstraints: GeneratedColumn.constraintIsAlways( - 'REFERENCES accounts (id) ON DELETE CASCADE', + 'REFERENCES notification_schedule (id) ON DELETE CASCADE', ), ); - static const VerificationMeta _sourceIdMeta = const VerificationMeta( - 'sourceId', - ); - @override - late final GeneratedColumn sourceId = GeneratedColumn( - 'source_id', - aliasedName, - false, - type: DriftSqlType.string, - requiredDuringInsert: true, - ); - static const VerificationMeta _itemIdMeta = const VerificationMeta('itemId'); - @override - late final GeneratedColumn itemId = GeneratedColumn( - 'item_id', - aliasedName, - false, - type: DriftSqlType.string, - requiredDuringInsert: true, - ); - static const VerificationMeta _locationTextMeta = const VerificationMeta( - 'locationText', - ); - @override - late final GeneratedColumn locationText = GeneratedColumn( - 'location_text', - aliasedName, - false, - type: DriftSqlType.string, - requiredDuringInsert: true, + static const VerificationMeta _generationMeta = const VerificationMeta( + 'generation', ); - static const VerificationMeta _labelMeta = const VerificationMeta('label'); @override - late final GeneratedColumn label = GeneratedColumn( - 'label', + late final GeneratedColumn generation = GeneratedColumn( + 'generation', aliasedName, false, type: DriftSqlType.string, requiredDuringInsert: true, ); - static const VerificationMeta _latitudeMeta = const VerificationMeta( - 'latitude', + static const VerificationMeta _platformIdMeta = const VerificationMeta( + 'platformId', ); @override - late final GeneratedColumn latitude = GeneratedColumn( - 'latitude', + late final GeneratedColumn platformId = GeneratedColumn( + 'platform_id', aliasedName, false, - type: DriftSqlType.double, + type: DriftSqlType.int, requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways('UNIQUE'), ); - static const VerificationMeta _longitudeMeta = const VerificationMeta( - 'longitude', + static const VerificationMeta _scheduledAtUtcMeta = const VerificationMeta( + 'scheduledAtUtc', ); @override - late final GeneratedColumn longitude = GeneratedColumn( - 'longitude', + late final GeneratedColumn scheduledAtUtc = GeneratedColumn( + 'scheduled_at_utc', aliasedName, false, - type: DriftSqlType.double, + type: DriftSqlType.int, requiredDuringInsert: true, ); - static const VerificationMeta _sourceMeta = const VerificationMeta('source'); + static const VerificationMeta _stateMeta = const VerificationMeta('state'); @override - late final GeneratedColumn source = GeneratedColumn( - 'source', + late final GeneratedColumn state = GeneratedColumn( + 'state', aliasedName, false, type: DriftSqlType.string, - requiredDuringInsert: true, + requiredDuringInsert: false, + defaultValue: const Constant('scheduled'), ); - static const VerificationMeta _attributionMeta = const VerificationMeta( - 'attribution', + static const VerificationMeta _updatedAtUtcMeta = const VerificationMeta( + 'updatedAtUtc', ); @override - late final GeneratedColumn attribution = GeneratedColumn( - 'attribution', + late final GeneratedColumn updatedAtUtc = GeneratedColumn( + 'updated_at_utc', aliasedName, false, - type: DriftSqlType.string, + type: DriftSqlType.int, requiredDuringInsert: true, ); @override List get $columns => [ - kind, - accountId, - sourceId, - itemId, - locationText, - label, - latitude, - longitude, - source, - attribution, + scheduleId, + generation, + platformId, + scheduledAtUtc, + state, + updatedAtUtc, ]; @override String get aliasedName => _alias ?? actualTableName; @override String get actualTableName => $name; - static const String $name = 'location_resolutions'; + static const String $name = 'android_notification_mappings'; @override VerificationContext validateIntegrity( - Insertable instance, { + Insertable instance, { bool isInserting = false, }) { final context = VerificationContext(); final data = instance.toColumns(true); - if (data.containsKey('kind')) { - context.handle( - _kindMeta, - kind.isAcceptableOrUnknown(data['kind']!, _kindMeta), - ); - } else if (isInserting) { - context.missing(_kindMeta); - } - if (data.containsKey('account_id')) { + if (data.containsKey('schedule_id')) { context.handle( - _accountIdMeta, - accountId.isAcceptableOrUnknown(data['account_id']!, _accountIdMeta), + _scheduleIdMeta, + scheduleId.isAcceptableOrUnknown(data['schedule_id']!, _scheduleIdMeta), ); } else if (isInserting) { - context.missing(_accountIdMeta); + context.missing(_scheduleIdMeta); } - if (data.containsKey('source_id')) { + if (data.containsKey('generation')) { context.handle( - _sourceIdMeta, - sourceId.isAcceptableOrUnknown(data['source_id']!, _sourceIdMeta), + _generationMeta, + generation.isAcceptableOrUnknown(data['generation']!, _generationMeta), ); } else if (isInserting) { - context.missing(_sourceIdMeta); + context.missing(_generationMeta); } - if (data.containsKey('item_id')) { + if (data.containsKey('platform_id')) { context.handle( - _itemIdMeta, - itemId.isAcceptableOrUnknown(data['item_id']!, _itemIdMeta), + _platformIdMeta, + platformId.isAcceptableOrUnknown(data['platform_id']!, _platformIdMeta), ); } else if (isInserting) { - context.missing(_itemIdMeta); + context.missing(_platformIdMeta); } - if (data.containsKey('location_text')) { + if (data.containsKey('scheduled_at_utc')) { context.handle( - _locationTextMeta, - locationText.isAcceptableOrUnknown( - data['location_text']!, - _locationTextMeta, + _scheduledAtUtcMeta, + scheduledAtUtc.isAcceptableOrUnknown( + data['scheduled_at_utc']!, + _scheduledAtUtcMeta, ), ); } else if (isInserting) { - context.missing(_locationTextMeta); - } - if (data.containsKey('label')) { - context.handle( - _labelMeta, - label.isAcceptableOrUnknown(data['label']!, _labelMeta), - ); - } else if (isInserting) { - context.missing(_labelMeta); - } - if (data.containsKey('latitude')) { - context.handle( - _latitudeMeta, - latitude.isAcceptableOrUnknown(data['latitude']!, _latitudeMeta), - ); - } else if (isInserting) { - context.missing(_latitudeMeta); - } - if (data.containsKey('longitude')) { - context.handle( - _longitudeMeta, - longitude.isAcceptableOrUnknown(data['longitude']!, _longitudeMeta), - ); - } else if (isInserting) { - context.missing(_longitudeMeta); + context.missing(_scheduledAtUtcMeta); } - if (data.containsKey('source')) { + if (data.containsKey('state')) { context.handle( - _sourceMeta, - source.isAcceptableOrUnknown(data['source']!, _sourceMeta), + _stateMeta, + state.isAcceptableOrUnknown(data['state']!, _stateMeta), ); - } else if (isInserting) { - context.missing(_sourceMeta); } - if (data.containsKey('attribution')) { + if (data.containsKey('updated_at_utc')) { context.handle( - _attributionMeta, - attribution.isAcceptableOrUnknown( - data['attribution']!, - _attributionMeta, + _updatedAtUtcMeta, + updatedAtUtc.isAcceptableOrUnknown( + data['updated_at_utc']!, + _updatedAtUtcMeta, ), ); } else if (isInserting) { - context.missing(_attributionMeta); + context.missing(_updatedAtUtcMeta); } return context; } @override - Set get $primaryKey => {kind, accountId, sourceId, itemId}; + Set get $primaryKey => {scheduleId}; @override - LocationResolution map(Map data, {String? tablePrefix}) { + AndroidNotificationMapping map( + Map data, { + String? tablePrefix, + }) { final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; - return LocationResolution( - kind: attachedDatabase.typeMapping.read( - DriftSqlType.string, - data['${effectivePrefix}kind'], - )!, - accountId: attachedDatabase.typeMapping.read( - DriftSqlType.string, - data['${effectivePrefix}account_id'], - )!, - sourceId: attachedDatabase.typeMapping.read( - DriftSqlType.string, - data['${effectivePrefix}source_id'], - )!, - itemId: attachedDatabase.typeMapping.read( - DriftSqlType.string, - data['${effectivePrefix}item_id'], - )!, - locationText: attachedDatabase.typeMapping.read( + return AndroidNotificationMapping( + scheduleId: attachedDatabase.typeMapping.read( DriftSqlType.string, - data['${effectivePrefix}location_text'], + data['${effectivePrefix}schedule_id'], )!, - label: attachedDatabase.typeMapping.read( + generation: attachedDatabase.typeMapping.read( DriftSqlType.string, - data['${effectivePrefix}label'], + data['${effectivePrefix}generation'], )!, - latitude: attachedDatabase.typeMapping.read( - DriftSqlType.double, - data['${effectivePrefix}latitude'], + platformId: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}platform_id'], )!, - longitude: attachedDatabase.typeMapping.read( - DriftSqlType.double, - data['${effectivePrefix}longitude'], + scheduledAtUtc: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}scheduled_at_utc'], )!, - source: attachedDatabase.typeMapping.read( + state: attachedDatabase.typeMapping.read( DriftSqlType.string, - data['${effectivePrefix}source'], + data['${effectivePrefix}state'], )!, - attribution: attachedDatabase.typeMapping.read( - DriftSqlType.string, - data['${effectivePrefix}attribution'], + updatedAtUtc: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}updated_at_utc'], )!, ); } @override - $LocationResolutionsTable createAlias(String alias) { - return $LocationResolutionsTable(attachedDatabase, alias); + $AndroidNotificationMappingsTable createAlias(String alias) { + return $AndroidNotificationMappingsTable(attachedDatabase, alias); } } -class LocationResolution extends DataClass - implements Insertable { - final String kind; - final String accountId; - final String sourceId; - final String itemId; - final String locationText; - final String label; - final double latitude; - final double longitude; - final String source; - final String attribution; - const LocationResolution({ - required this.kind, - required this.accountId, - required this.sourceId, - required this.itemId, - required this.locationText, - required this.label, - required this.latitude, - required this.longitude, - required this.source, - required this.attribution, +class AndroidNotificationMapping extends DataClass + implements Insertable { + final String scheduleId; + final String generation; + final int platformId; + final int scheduledAtUtc; + final String state; + final int updatedAtUtc; + const AndroidNotificationMapping({ + required this.scheduleId, + required this.generation, + required this.platformId, + required this.scheduledAtUtc, + required this.state, + required this.updatedAtUtc, }); @override Map toColumns(bool nullToAbsent) { final map = {}; - map['kind'] = Variable(kind); - map['account_id'] = Variable(accountId); - map['source_id'] = Variable(sourceId); - map['item_id'] = Variable(itemId); - map['location_text'] = Variable(locationText); - map['label'] = Variable(label); - map['latitude'] = Variable(latitude); - map['longitude'] = Variable(longitude); - map['source'] = Variable(source); - map['attribution'] = Variable(attribution); + map['schedule_id'] = Variable(scheduleId); + map['generation'] = Variable(generation); + map['platform_id'] = Variable(platformId); + map['scheduled_at_utc'] = Variable(scheduledAtUtc); + map['state'] = Variable(state); + map['updated_at_utc'] = Variable(updatedAtUtc); return map; } - LocationResolutionsCompanion toCompanion(bool nullToAbsent) { - return LocationResolutionsCompanion( - kind: Value(kind), - accountId: Value(accountId), - sourceId: Value(sourceId), - itemId: Value(itemId), - locationText: Value(locationText), - label: Value(label), - latitude: Value(latitude), - longitude: Value(longitude), - source: Value(source), - attribution: Value(attribution), + AndroidNotificationMappingsCompanion toCompanion(bool nullToAbsent) { + return AndroidNotificationMappingsCompanion( + scheduleId: Value(scheduleId), + generation: Value(generation), + platformId: Value(platformId), + scheduledAtUtc: Value(scheduledAtUtc), + state: Value(state), + updatedAtUtc: Value(updatedAtUtc), ); } - factory LocationResolution.fromJson( + factory AndroidNotificationMapping.fromJson( Map json, { ValueSerializer? serializer, }) { serializer ??= driftRuntimeOptions.defaultSerializer; - return LocationResolution( - kind: serializer.fromJson(json['kind']), - accountId: serializer.fromJson(json['accountId']), - sourceId: serializer.fromJson(json['sourceId']), - itemId: serializer.fromJson(json['itemId']), - locationText: serializer.fromJson(json['locationText']), - label: serializer.fromJson(json['label']), - latitude: serializer.fromJson(json['latitude']), - longitude: serializer.fromJson(json['longitude']), - source: serializer.fromJson(json['source']), - attribution: serializer.fromJson(json['attribution']), + return AndroidNotificationMapping( + scheduleId: serializer.fromJson(json['scheduleId']), + generation: serializer.fromJson(json['generation']), + platformId: serializer.fromJson(json['platformId']), + scheduledAtUtc: serializer.fromJson(json['scheduledAtUtc']), + state: serializer.fromJson(json['state']), + updatedAtUtc: serializer.fromJson(json['updatedAtUtc']), ); } @override Map toJson({ValueSerializer? serializer}) { serializer ??= driftRuntimeOptions.defaultSerializer; return { - 'kind': serializer.toJson(kind), - 'accountId': serializer.toJson(accountId), - 'sourceId': serializer.toJson(sourceId), - 'itemId': serializer.toJson(itemId), - 'locationText': serializer.toJson(locationText), - 'label': serializer.toJson(label), - 'latitude': serializer.toJson(latitude), - 'longitude': serializer.toJson(longitude), - 'source': serializer.toJson(source), - 'attribution': serializer.toJson(attribution), + 'scheduleId': serializer.toJson(scheduleId), + 'generation': serializer.toJson(generation), + 'platformId': serializer.toJson(platformId), + 'scheduledAtUtc': serializer.toJson(scheduledAtUtc), + 'state': serializer.toJson(state), + 'updatedAtUtc': serializer.toJson(updatedAtUtc), }; } - LocationResolution copyWith({ - String? kind, - String? accountId, - String? sourceId, - String? itemId, - String? locationText, - String? label, - double? latitude, - double? longitude, - String? source, - String? attribution, - }) => LocationResolution( - kind: kind ?? this.kind, - accountId: accountId ?? this.accountId, - sourceId: sourceId ?? this.sourceId, - itemId: itemId ?? this.itemId, - locationText: locationText ?? this.locationText, - label: label ?? this.label, - latitude: latitude ?? this.latitude, - longitude: longitude ?? this.longitude, - source: source ?? this.source, - attribution: attribution ?? this.attribution, + AndroidNotificationMapping copyWith({ + String? scheduleId, + String? generation, + int? platformId, + int? scheduledAtUtc, + String? state, + int? updatedAtUtc, + }) => AndroidNotificationMapping( + scheduleId: scheduleId ?? this.scheduleId, + generation: generation ?? this.generation, + platformId: platformId ?? this.platformId, + scheduledAtUtc: scheduledAtUtc ?? this.scheduledAtUtc, + state: state ?? this.state, + updatedAtUtc: updatedAtUtc ?? this.updatedAtUtc, ); - LocationResolution copyWithCompanion(LocationResolutionsCompanion data) { - return LocationResolution( - kind: data.kind.present ? data.kind.value : this.kind, - accountId: data.accountId.present ? data.accountId.value : this.accountId, - sourceId: data.sourceId.present ? data.sourceId.value : this.sourceId, - itemId: data.itemId.present ? data.itemId.value : this.itemId, - locationText: data.locationText.present - ? data.locationText.value - : this.locationText, - label: data.label.present ? data.label.value : this.label, - latitude: data.latitude.present ? data.latitude.value : this.latitude, - longitude: data.longitude.present ? data.longitude.value : this.longitude, - source: data.source.present ? data.source.value : this.source, - attribution: data.attribution.present - ? data.attribution.value - : this.attribution, + AndroidNotificationMapping copyWithCompanion( + AndroidNotificationMappingsCompanion data, + ) { + return AndroidNotificationMapping( + scheduleId: data.scheduleId.present + ? data.scheduleId.value + : this.scheduleId, + generation: data.generation.present + ? data.generation.value + : this.generation, + platformId: data.platformId.present + ? data.platformId.value + : this.platformId, + scheduledAtUtc: data.scheduledAtUtc.present + ? data.scheduledAtUtc.value + : this.scheduledAtUtc, + state: data.state.present ? data.state.value : this.state, + updatedAtUtc: data.updatedAtUtc.present + ? data.updatedAtUtc.value + : this.updatedAtUtc, ); } @override String toString() { - return (StringBuffer('LocationResolution(') - ..write('kind: $kind, ') - ..write('accountId: $accountId, ') - ..write('sourceId: $sourceId, ') - ..write('itemId: $itemId, ') - ..write('locationText: $locationText, ') - ..write('label: $label, ') - ..write('latitude: $latitude, ') - ..write('longitude: $longitude, ') - ..write('source: $source, ') - ..write('attribution: $attribution') + return (StringBuffer('AndroidNotificationMapping(') + ..write('scheduleId: $scheduleId, ') + ..write('generation: $generation, ') + ..write('platformId: $platformId, ') + ..write('scheduledAtUtc: $scheduledAtUtc, ') + ..write('state: $state, ') + ..write('updatedAtUtc: $updatedAtUtc') ..write(')')) .toString(); } @override int get hashCode => Object.hash( - kind, - accountId, - sourceId, - itemId, - locationText, - label, - latitude, - longitude, - source, - attribution, + scheduleId, + generation, + platformId, + scheduledAtUtc, + state, + updatedAtUtc, ); @override bool operator ==(Object other) => identical(this, other) || - (other is LocationResolution && - other.kind == this.kind && - other.accountId == this.accountId && - other.sourceId == this.sourceId && - other.itemId == this.itemId && - other.locationText == this.locationText && - other.label == this.label && - other.latitude == this.latitude && - other.longitude == this.longitude && - other.source == this.source && - other.attribution == this.attribution); + (other is AndroidNotificationMapping && + other.scheduleId == this.scheduleId && + other.generation == this.generation && + other.platformId == this.platformId && + other.scheduledAtUtc == this.scheduledAtUtc && + other.state == this.state && + other.updatedAtUtc == this.updatedAtUtc); } -class LocationResolutionsCompanion extends UpdateCompanion { - final Value kind; - final Value accountId; - final Value sourceId; - final Value itemId; - final Value locationText; - final Value label; - final Value latitude; - final Value longitude; - final Value source; - final Value attribution; +class AndroidNotificationMappingsCompanion + extends UpdateCompanion { + final Value scheduleId; + final Value generation; + final Value platformId; + final Value scheduledAtUtc; + final Value state; + final Value updatedAtUtc; final Value rowid; - const LocationResolutionsCompanion({ - this.kind = const Value.absent(), - this.accountId = const Value.absent(), - this.sourceId = const Value.absent(), - this.itemId = const Value.absent(), - this.locationText = const Value.absent(), - this.label = const Value.absent(), - this.latitude = const Value.absent(), - this.longitude = const Value.absent(), - this.source = const Value.absent(), - this.attribution = const Value.absent(), + const AndroidNotificationMappingsCompanion({ + this.scheduleId = const Value.absent(), + this.generation = const Value.absent(), + this.platformId = const Value.absent(), + this.scheduledAtUtc = const Value.absent(), + this.state = const Value.absent(), + this.updatedAtUtc = const Value.absent(), this.rowid = const Value.absent(), }); - LocationResolutionsCompanion.insert({ - required String kind, - required String accountId, - required String sourceId, - required String itemId, - required String locationText, - required String label, - required double latitude, - required double longitude, - required String source, - required String attribution, + AndroidNotificationMappingsCompanion.insert({ + required String scheduleId, + required String generation, + required int platformId, + required int scheduledAtUtc, + this.state = const Value.absent(), + required int updatedAtUtc, this.rowid = const Value.absent(), - }) : kind = Value(kind), - accountId = Value(accountId), - sourceId = Value(sourceId), - itemId = Value(itemId), - locationText = Value(locationText), - label = Value(label), - latitude = Value(latitude), - longitude = Value(longitude), - source = Value(source), - attribution = Value(attribution); - static Insertable custom({ - Expression? kind, - Expression? accountId, - Expression? sourceId, - Expression? itemId, - Expression? locationText, - Expression? label, - Expression? latitude, - Expression? longitude, - Expression? source, - Expression? attribution, + }) : scheduleId = Value(scheduleId), + generation = Value(generation), + platformId = Value(platformId), + scheduledAtUtc = Value(scheduledAtUtc), + updatedAtUtc = Value(updatedAtUtc); + static Insertable custom({ + Expression? scheduleId, + Expression? generation, + Expression? platformId, + Expression? scheduledAtUtc, + Expression? state, + Expression? updatedAtUtc, Expression? rowid, }) { return RawValuesInsertable({ - if (kind != null) 'kind': kind, - if (accountId != null) 'account_id': accountId, - if (sourceId != null) 'source_id': sourceId, - if (itemId != null) 'item_id': itemId, - if (locationText != null) 'location_text': locationText, - if (label != null) 'label': label, - if (latitude != null) 'latitude': latitude, - if (longitude != null) 'longitude': longitude, - if (source != null) 'source': source, - if (attribution != null) 'attribution': attribution, + if (scheduleId != null) 'schedule_id': scheduleId, + if (generation != null) 'generation': generation, + if (platformId != null) 'platform_id': platformId, + if (scheduledAtUtc != null) 'scheduled_at_utc': scheduledAtUtc, + if (state != null) 'state': state, + if (updatedAtUtc != null) 'updated_at_utc': updatedAtUtc, if (rowid != null) 'rowid': rowid, }); } - LocationResolutionsCompanion copyWith({ - Value? kind, - Value? accountId, - Value? sourceId, - Value? itemId, - Value? locationText, - Value? label, - Value? latitude, - Value? longitude, - Value? source, - Value? attribution, + AndroidNotificationMappingsCompanion copyWith({ + Value? scheduleId, + Value? generation, + Value? platformId, + Value? scheduledAtUtc, + Value? state, + Value? updatedAtUtc, Value? rowid, }) { - return LocationResolutionsCompanion( - kind: kind ?? this.kind, - accountId: accountId ?? this.accountId, - sourceId: sourceId ?? this.sourceId, - itemId: itemId ?? this.itemId, - locationText: locationText ?? this.locationText, - label: label ?? this.label, - latitude: latitude ?? this.latitude, - longitude: longitude ?? this.longitude, - source: source ?? this.source, - attribution: attribution ?? this.attribution, + return AndroidNotificationMappingsCompanion( + scheduleId: scheduleId ?? this.scheduleId, + generation: generation ?? this.generation, + platformId: platformId ?? this.platformId, + scheduledAtUtc: scheduledAtUtc ?? this.scheduledAtUtc, + state: state ?? this.state, + updatedAtUtc: updatedAtUtc ?? this.updatedAtUtc, rowid: rowid ?? this.rowid, ); } @@ -25727,35 +25572,23 @@ class LocationResolutionsCompanion extends UpdateCompanion { @override Map toColumns(bool nullToAbsent) { final map = {}; - if (kind.present) { - map['kind'] = Variable(kind.value); - } - if (accountId.present) { - map['account_id'] = Variable(accountId.value); - } - if (sourceId.present) { - map['source_id'] = Variable(sourceId.value); - } - if (itemId.present) { - map['item_id'] = Variable(itemId.value); - } - if (locationText.present) { - map['location_text'] = Variable(locationText.value); + if (scheduleId.present) { + map['schedule_id'] = Variable(scheduleId.value); } - if (label.present) { - map['label'] = Variable(label.value); + if (generation.present) { + map['generation'] = Variable(generation.value); } - if (latitude.present) { - map['latitude'] = Variable(latitude.value); + if (platformId.present) { + map['platform_id'] = Variable(platformId.value); } - if (longitude.present) { - map['longitude'] = Variable(longitude.value); + if (scheduledAtUtc.present) { + map['scheduled_at_utc'] = Variable(scheduledAtUtc.value); } - if (source.present) { - map['source'] = Variable(source.value); + if (state.present) { + map['state'] = Variable(state.value); } - if (attribution.present) { - map['attribution'] = Variable(attribution.value); + if (updatedAtUtc.present) { + map['updated_at_utc'] = Variable(updatedAtUtc.value); } if (rowid.present) { map['rowid'] = Variable(rowid.value); @@ -25765,149 +25598,1260 @@ class LocationResolutionsCompanion extends UpdateCompanion { @override String toString() { - return (StringBuffer('LocationResolutionsCompanion(') - ..write('kind: $kind, ') - ..write('accountId: $accountId, ') - ..write('sourceId: $sourceId, ') - ..write('itemId: $itemId, ') - ..write('locationText: $locationText, ') - ..write('label: $label, ') - ..write('latitude: $latitude, ') - ..write('longitude: $longitude, ') - ..write('source: $source, ') - ..write('attribution: $attribution, ') + return (StringBuffer('AndroidNotificationMappingsCompanion(') + ..write('scheduleId: $scheduleId, ') + ..write('generation: $generation, ') + ..write('platformId: $platformId, ') + ..write('scheduledAtUtc: $scheduledAtUtc, ') + ..write('state: $state, ') + ..write('updatedAtUtc: $updatedAtUtc, ') ..write('rowid: $rowid') ..write(')')) .toString(); } } -abstract class _$AppDatabase extends GeneratedDatabase { - _$AppDatabase(QueryExecutor e) : super(e); - $AppDatabaseManager get managers => $AppDatabaseManager(this); - late final $AccountsTable accounts = $AccountsTable(this); - late final $DavAccountServicesTable davAccountServices = - $DavAccountServicesTable(this); - late final $DavCollectionsTable davCollections = $DavCollectionsTable(this); - late final $DavObjectsTable davObjects = $DavObjectsTable(this); - late final $DavObjectComponentsTable davObjectComponents = - $DavObjectComponentsTable(this); - late final $DavConflictSnapshotsTable davConflictSnapshots = - $DavConflictSnapshotsTable(this); - late final $TaskListsTable taskLists = $TaskListsTable(this); - late final $TasksTable tasks = $TasksTable(this); - late final $PendingOpsTable pendingOps = $PendingOpsTable(this); - late final $SyncRunsTable syncRuns = $SyncRunsTable(this); - late final $CalendarSourcesTable calendarSources = $CalendarSourcesTable( - this, +class $AndroidDailySummarySchedulesTable extends AndroidDailySummarySchedules + with + TableInfo< + $AndroidDailySummarySchedulesTable, + AndroidDailySummarySchedule + > { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + $AndroidDailySummarySchedulesTable(this.attachedDatabase, [this._alias]); + static const VerificationMeta _localDateMeta = const VerificationMeta( + 'localDate', ); - late final $CalendarEventsTable calendarEvents = $CalendarEventsTable(this); - late final $IcalImportReceiptsTable icalImportReceipts = - $IcalImportReceiptsTable(this); - late final $WebCalSubscriptionsTable webCalSubscriptions = - $WebCalSubscriptionsTable(this); - late final $CalendarEventAttendeesTable calendarEventAttendees = - $CalendarEventAttendeesTable(this); - late final $CalendarEventRemindersTable calendarEventReminders = - $CalendarEventRemindersTable(this); - late final $SyncCursorsTable syncCursors = $SyncCursorsTable(this); - late final $CalendarColorsTable calendarColors = $CalendarColorsTable(this); - late final $ScheduleItemOverridesTable scheduleItemOverrides = - $ScheduleItemOverridesTable(this); - late final $NotificationScheduleTable notificationSchedule = - $NotificationScheduleTable(this); - late final $LocationResolutionsTable locationResolutions = - $LocationResolutionsTable(this); - late final TaskListsDao taskListsDao = TaskListsDao(this as AppDatabase); - late final TasksDao tasksDao = TasksDao(this as AppDatabase); - late final PendingOpsDao pendingOpsDao = PendingOpsDao(this as AppDatabase); - late final SyncRunsDao syncRunsDao = SyncRunsDao(this as AppDatabase); @override - Iterable> get allTables => - allSchemaEntities.whereType>(); + late final GeneratedColumn localDate = GeneratedColumn( + 'local_date', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _platformIdMeta = const VerificationMeta( + 'platformId', + ); @override - List get allSchemaEntities => [ - accounts, - davAccountServices, - davCollections, - davObjects, - davObjectComponents, - davConflictSnapshots, - taskLists, - tasks, - pendingOps, - syncRuns, - calendarSources, - calendarEvents, - icalImportReceipts, - webCalSubscriptions, - calendarEventAttendees, - calendarEventReminders, - syncCursors, - calendarColors, - scheduleItemOverrides, - notificationSchedule, - locationResolutions, + late final GeneratedColumn platformId = GeneratedColumn( + 'platform_id', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + static const VerificationMeta _generationMeta = const VerificationMeta( + 'generation', + ); + @override + late final GeneratedColumn generation = GeneratedColumn( + 'generation', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _scheduledAtUtcMeta = const VerificationMeta( + 'scheduledAtUtc', + ); + @override + late final GeneratedColumn scheduledAtUtc = GeneratedColumn( + 'scheduled_at_utc', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + static const VerificationMeta _taskCountMeta = const VerificationMeta( + 'taskCount', + ); + @override + late final GeneratedColumn taskCount = GeneratedColumn( + 'task_count', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + static const VerificationMeta _stateMeta = const VerificationMeta('state'); + @override + late final GeneratedColumn state = GeneratedColumn( + 'state', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: false, + defaultValue: const Constant('scheduled'), + ); + static const VerificationMeta _updatedAtUtcMeta = const VerificationMeta( + 'updatedAtUtc', + ); + @override + late final GeneratedColumn updatedAtUtc = GeneratedColumn( + 'updated_at_utc', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + @override + List get $columns => [ + localDate, + platformId, + generation, + scheduledAtUtc, + taskCount, + state, + updatedAtUtc, ]; @override - StreamQueryUpdateRules get streamUpdateRules => const StreamQueryUpdateRules([ - WritePropagation( - on: TableUpdateQuery.onTableName( - 'accounts', - limitUpdateKind: UpdateKind.delete, - ), - result: [TableUpdate('dav_account_services', kind: UpdateKind.delete)], - ), - WritePropagation( - on: TableUpdateQuery.onTableName( - 'accounts', - limitUpdateKind: UpdateKind.delete, - ), - result: [TableUpdate('dav_collections', kind: UpdateKind.delete)], - ), - WritePropagation( - on: TableUpdateQuery.onTableName( - 'accounts', - limitUpdateKind: UpdateKind.delete, - ), - result: [TableUpdate('dav_objects', kind: UpdateKind.delete)], - ), - WritePropagation( - on: TableUpdateQuery.onTableName( - 'dav_collections', - limitUpdateKind: UpdateKind.delete, - ), - result: [TableUpdate('dav_objects', kind: UpdateKind.delete)], - ), - WritePropagation( - on: TableUpdateQuery.onTableName( - 'dav_objects', - limitUpdateKind: UpdateKind.delete, - ), - result: [TableUpdate('dav_object_components', kind: UpdateKind.delete)], - ), - WritePropagation( - on: TableUpdateQuery.onTableName( - 'accounts', - limitUpdateKind: UpdateKind.delete, - ), - result: [TableUpdate('dav_conflict_snapshots', kind: UpdateKind.delete)], - ), - WritePropagation( - on: TableUpdateQuery.onTableName( - 'dav_collections', - limitUpdateKind: UpdateKind.delete, - ), - result: [TableUpdate('dav_conflict_snapshots', kind: UpdateKind.update)], - ), - WritePropagation( - on: TableUpdateQuery.onTableName( - 'dav_objects', - limitUpdateKind: UpdateKind.delete, - ), - result: [TableUpdate('dav_conflict_snapshots', kind: UpdateKind.update)], - ), - WritePropagation( + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'android_daily_summary_schedules'; + @override + VerificationContext validateIntegrity( + Insertable instance, { + bool isInserting = false, + }) { + final context = VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('local_date')) { + context.handle( + _localDateMeta, + localDate.isAcceptableOrUnknown(data['local_date']!, _localDateMeta), + ); + } else if (isInserting) { + context.missing(_localDateMeta); + } + if (data.containsKey('platform_id')) { + context.handle( + _platformIdMeta, + platformId.isAcceptableOrUnknown(data['platform_id']!, _platformIdMeta), + ); + } else if (isInserting) { + context.missing(_platformIdMeta); + } + if (data.containsKey('generation')) { + context.handle( + _generationMeta, + generation.isAcceptableOrUnknown(data['generation']!, _generationMeta), + ); + } else if (isInserting) { + context.missing(_generationMeta); + } + if (data.containsKey('scheduled_at_utc')) { + context.handle( + _scheduledAtUtcMeta, + scheduledAtUtc.isAcceptableOrUnknown( + data['scheduled_at_utc']!, + _scheduledAtUtcMeta, + ), + ); + } else if (isInserting) { + context.missing(_scheduledAtUtcMeta); + } + if (data.containsKey('task_count')) { + context.handle( + _taskCountMeta, + taskCount.isAcceptableOrUnknown(data['task_count']!, _taskCountMeta), + ); + } else if (isInserting) { + context.missing(_taskCountMeta); + } + if (data.containsKey('state')) { + context.handle( + _stateMeta, + state.isAcceptableOrUnknown(data['state']!, _stateMeta), + ); + } + if (data.containsKey('updated_at_utc')) { + context.handle( + _updatedAtUtcMeta, + updatedAtUtc.isAcceptableOrUnknown( + data['updated_at_utc']!, + _updatedAtUtcMeta, + ), + ); + } else if (isInserting) { + context.missing(_updatedAtUtcMeta); + } + return context; + } + + @override + Set get $primaryKey => {localDate}; + @override + AndroidDailySummarySchedule map( + Map data, { + String? tablePrefix, + }) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return AndroidDailySummarySchedule( + localDate: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}local_date'], + )!, + platformId: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}platform_id'], + )!, + generation: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}generation'], + )!, + scheduledAtUtc: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}scheduled_at_utc'], + )!, + taskCount: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}task_count'], + )!, + state: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}state'], + )!, + updatedAtUtc: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}updated_at_utc'], + )!, + ); + } + + @override + $AndroidDailySummarySchedulesTable createAlias(String alias) { + return $AndroidDailySummarySchedulesTable(attachedDatabase, alias); + } +} + +class AndroidDailySummarySchedule extends DataClass + implements Insertable { + final String localDate; + final int platformId; + final String generation; + final int scheduledAtUtc; + final int taskCount; + final String state; + final int updatedAtUtc; + const AndroidDailySummarySchedule({ + required this.localDate, + required this.platformId, + required this.generation, + required this.scheduledAtUtc, + required this.taskCount, + required this.state, + required this.updatedAtUtc, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['local_date'] = Variable(localDate); + map['platform_id'] = Variable(platformId); + map['generation'] = Variable(generation); + map['scheduled_at_utc'] = Variable(scheduledAtUtc); + map['task_count'] = Variable(taskCount); + map['state'] = Variable(state); + map['updated_at_utc'] = Variable(updatedAtUtc); + return map; + } + + AndroidDailySummarySchedulesCompanion toCompanion(bool nullToAbsent) { + return AndroidDailySummarySchedulesCompanion( + localDate: Value(localDate), + platformId: Value(platformId), + generation: Value(generation), + scheduledAtUtc: Value(scheduledAtUtc), + taskCount: Value(taskCount), + state: Value(state), + updatedAtUtc: Value(updatedAtUtc), + ); + } + + factory AndroidDailySummarySchedule.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return AndroidDailySummarySchedule( + localDate: serializer.fromJson(json['localDate']), + platformId: serializer.fromJson(json['platformId']), + generation: serializer.fromJson(json['generation']), + scheduledAtUtc: serializer.fromJson(json['scheduledAtUtc']), + taskCount: serializer.fromJson(json['taskCount']), + state: serializer.fromJson(json['state']), + updatedAtUtc: serializer.fromJson(json['updatedAtUtc']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'localDate': serializer.toJson(localDate), + 'platformId': serializer.toJson(platformId), + 'generation': serializer.toJson(generation), + 'scheduledAtUtc': serializer.toJson(scheduledAtUtc), + 'taskCount': serializer.toJson(taskCount), + 'state': serializer.toJson(state), + 'updatedAtUtc': serializer.toJson(updatedAtUtc), + }; + } + + AndroidDailySummarySchedule copyWith({ + String? localDate, + int? platformId, + String? generation, + int? scheduledAtUtc, + int? taskCount, + String? state, + int? updatedAtUtc, + }) => AndroidDailySummarySchedule( + localDate: localDate ?? this.localDate, + platformId: platformId ?? this.platformId, + generation: generation ?? this.generation, + scheduledAtUtc: scheduledAtUtc ?? this.scheduledAtUtc, + taskCount: taskCount ?? this.taskCount, + state: state ?? this.state, + updatedAtUtc: updatedAtUtc ?? this.updatedAtUtc, + ); + AndroidDailySummarySchedule copyWithCompanion( + AndroidDailySummarySchedulesCompanion data, + ) { + return AndroidDailySummarySchedule( + localDate: data.localDate.present ? data.localDate.value : this.localDate, + platformId: data.platformId.present + ? data.platformId.value + : this.platformId, + generation: data.generation.present + ? data.generation.value + : this.generation, + scheduledAtUtc: data.scheduledAtUtc.present + ? data.scheduledAtUtc.value + : this.scheduledAtUtc, + taskCount: data.taskCount.present ? data.taskCount.value : this.taskCount, + state: data.state.present ? data.state.value : this.state, + updatedAtUtc: data.updatedAtUtc.present + ? data.updatedAtUtc.value + : this.updatedAtUtc, + ); + } + + @override + String toString() { + return (StringBuffer('AndroidDailySummarySchedule(') + ..write('localDate: $localDate, ') + ..write('platformId: $platformId, ') + ..write('generation: $generation, ') + ..write('scheduledAtUtc: $scheduledAtUtc, ') + ..write('taskCount: $taskCount, ') + ..write('state: $state, ') + ..write('updatedAtUtc: $updatedAtUtc') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash( + localDate, + platformId, + generation, + scheduledAtUtc, + taskCount, + state, + updatedAtUtc, + ); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is AndroidDailySummarySchedule && + other.localDate == this.localDate && + other.platformId == this.platformId && + other.generation == this.generation && + other.scheduledAtUtc == this.scheduledAtUtc && + other.taskCount == this.taskCount && + other.state == this.state && + other.updatedAtUtc == this.updatedAtUtc); +} + +class AndroidDailySummarySchedulesCompanion + extends UpdateCompanion { + final Value localDate; + final Value platformId; + final Value generation; + final Value scheduledAtUtc; + final Value taskCount; + final Value state; + final Value updatedAtUtc; + final Value rowid; + const AndroidDailySummarySchedulesCompanion({ + this.localDate = const Value.absent(), + this.platformId = const Value.absent(), + this.generation = const Value.absent(), + this.scheduledAtUtc = const Value.absent(), + this.taskCount = const Value.absent(), + this.state = const Value.absent(), + this.updatedAtUtc = const Value.absent(), + this.rowid = const Value.absent(), + }); + AndroidDailySummarySchedulesCompanion.insert({ + required String localDate, + required int platformId, + required String generation, + required int scheduledAtUtc, + required int taskCount, + this.state = const Value.absent(), + required int updatedAtUtc, + this.rowid = const Value.absent(), + }) : localDate = Value(localDate), + platformId = Value(platformId), + generation = Value(generation), + scheduledAtUtc = Value(scheduledAtUtc), + taskCount = Value(taskCount), + updatedAtUtc = Value(updatedAtUtc); + static Insertable custom({ + Expression? localDate, + Expression? platformId, + Expression? generation, + Expression? scheduledAtUtc, + Expression? taskCount, + Expression? state, + Expression? updatedAtUtc, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (localDate != null) 'local_date': localDate, + if (platformId != null) 'platform_id': platformId, + if (generation != null) 'generation': generation, + if (scheduledAtUtc != null) 'scheduled_at_utc': scheduledAtUtc, + if (taskCount != null) 'task_count': taskCount, + if (state != null) 'state': state, + if (updatedAtUtc != null) 'updated_at_utc': updatedAtUtc, + if (rowid != null) 'rowid': rowid, + }); + } + + AndroidDailySummarySchedulesCompanion copyWith({ + Value? localDate, + Value? platformId, + Value? generation, + Value? scheduledAtUtc, + Value? taskCount, + Value? state, + Value? updatedAtUtc, + Value? rowid, + }) { + return AndroidDailySummarySchedulesCompanion( + localDate: localDate ?? this.localDate, + platformId: platformId ?? this.platformId, + generation: generation ?? this.generation, + scheduledAtUtc: scheduledAtUtc ?? this.scheduledAtUtc, + taskCount: taskCount ?? this.taskCount, + state: state ?? this.state, + updatedAtUtc: updatedAtUtc ?? this.updatedAtUtc, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (localDate.present) { + map['local_date'] = Variable(localDate.value); + } + if (platformId.present) { + map['platform_id'] = Variable(platformId.value); + } + if (generation.present) { + map['generation'] = Variable(generation.value); + } + if (scheduledAtUtc.present) { + map['scheduled_at_utc'] = Variable(scheduledAtUtc.value); + } + if (taskCount.present) { + map['task_count'] = Variable(taskCount.value); + } + if (state.present) { + map['state'] = Variable(state.value); + } + if (updatedAtUtc.present) { + map['updated_at_utc'] = Variable(updatedAtUtc.value); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('AndroidDailySummarySchedulesCompanion(') + ..write('localDate: $localDate, ') + ..write('platformId: $platformId, ') + ..write('generation: $generation, ') + ..write('scheduledAtUtc: $scheduledAtUtc, ') + ..write('taskCount: $taskCount, ') + ..write('state: $state, ') + ..write('updatedAtUtc: $updatedAtUtc, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + +class $LocationResolutionsTable extends LocationResolutions + with TableInfo<$LocationResolutionsTable, LocationResolution> { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + $LocationResolutionsTable(this.attachedDatabase, [this._alias]); + static const VerificationMeta _kindMeta = const VerificationMeta('kind'); + @override + late final GeneratedColumn kind = GeneratedColumn( + 'kind', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _accountIdMeta = const VerificationMeta( + 'accountId', + ); + @override + late final GeneratedColumn accountId = GeneratedColumn( + 'account_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'REFERENCES accounts (id) ON DELETE CASCADE', + ), + ); + static const VerificationMeta _sourceIdMeta = const VerificationMeta( + 'sourceId', + ); + @override + late final GeneratedColumn sourceId = GeneratedColumn( + 'source_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _itemIdMeta = const VerificationMeta('itemId'); + @override + late final GeneratedColumn itemId = GeneratedColumn( + 'item_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _locationTextMeta = const VerificationMeta( + 'locationText', + ); + @override + late final GeneratedColumn locationText = GeneratedColumn( + 'location_text', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _labelMeta = const VerificationMeta('label'); + @override + late final GeneratedColumn label = GeneratedColumn( + 'label', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _latitudeMeta = const VerificationMeta( + 'latitude', + ); + @override + late final GeneratedColumn latitude = GeneratedColumn( + 'latitude', + aliasedName, + false, + type: DriftSqlType.double, + requiredDuringInsert: true, + ); + static const VerificationMeta _longitudeMeta = const VerificationMeta( + 'longitude', + ); + @override + late final GeneratedColumn longitude = GeneratedColumn( + 'longitude', + aliasedName, + false, + type: DriftSqlType.double, + requiredDuringInsert: true, + ); + static const VerificationMeta _sourceMeta = const VerificationMeta('source'); + @override + late final GeneratedColumn source = GeneratedColumn( + 'source', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _attributionMeta = const VerificationMeta( + 'attribution', + ); + @override + late final GeneratedColumn attribution = GeneratedColumn( + 'attribution', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + @override + List get $columns => [ + kind, + accountId, + sourceId, + itemId, + locationText, + label, + latitude, + longitude, + source, + attribution, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'location_resolutions'; + @override + VerificationContext validateIntegrity( + Insertable instance, { + bool isInserting = false, + }) { + final context = VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('kind')) { + context.handle( + _kindMeta, + kind.isAcceptableOrUnknown(data['kind']!, _kindMeta), + ); + } else if (isInserting) { + context.missing(_kindMeta); + } + if (data.containsKey('account_id')) { + context.handle( + _accountIdMeta, + accountId.isAcceptableOrUnknown(data['account_id']!, _accountIdMeta), + ); + } else if (isInserting) { + context.missing(_accountIdMeta); + } + if (data.containsKey('source_id')) { + context.handle( + _sourceIdMeta, + sourceId.isAcceptableOrUnknown(data['source_id']!, _sourceIdMeta), + ); + } else if (isInserting) { + context.missing(_sourceIdMeta); + } + if (data.containsKey('item_id')) { + context.handle( + _itemIdMeta, + itemId.isAcceptableOrUnknown(data['item_id']!, _itemIdMeta), + ); + } else if (isInserting) { + context.missing(_itemIdMeta); + } + if (data.containsKey('location_text')) { + context.handle( + _locationTextMeta, + locationText.isAcceptableOrUnknown( + data['location_text']!, + _locationTextMeta, + ), + ); + } else if (isInserting) { + context.missing(_locationTextMeta); + } + if (data.containsKey('label')) { + context.handle( + _labelMeta, + label.isAcceptableOrUnknown(data['label']!, _labelMeta), + ); + } else if (isInserting) { + context.missing(_labelMeta); + } + if (data.containsKey('latitude')) { + context.handle( + _latitudeMeta, + latitude.isAcceptableOrUnknown(data['latitude']!, _latitudeMeta), + ); + } else if (isInserting) { + context.missing(_latitudeMeta); + } + if (data.containsKey('longitude')) { + context.handle( + _longitudeMeta, + longitude.isAcceptableOrUnknown(data['longitude']!, _longitudeMeta), + ); + } else if (isInserting) { + context.missing(_longitudeMeta); + } + if (data.containsKey('source')) { + context.handle( + _sourceMeta, + source.isAcceptableOrUnknown(data['source']!, _sourceMeta), + ); + } else if (isInserting) { + context.missing(_sourceMeta); + } + if (data.containsKey('attribution')) { + context.handle( + _attributionMeta, + attribution.isAcceptableOrUnknown( + data['attribution']!, + _attributionMeta, + ), + ); + } else if (isInserting) { + context.missing(_attributionMeta); + } + return context; + } + + @override + Set get $primaryKey => {kind, accountId, sourceId, itemId}; + @override + LocationResolution map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return LocationResolution( + kind: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}kind'], + )!, + accountId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}account_id'], + )!, + sourceId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}source_id'], + )!, + itemId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}item_id'], + )!, + locationText: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}location_text'], + )!, + label: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}label'], + )!, + latitude: attachedDatabase.typeMapping.read( + DriftSqlType.double, + data['${effectivePrefix}latitude'], + )!, + longitude: attachedDatabase.typeMapping.read( + DriftSqlType.double, + data['${effectivePrefix}longitude'], + )!, + source: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}source'], + )!, + attribution: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}attribution'], + )!, + ); + } + + @override + $LocationResolutionsTable createAlias(String alias) { + return $LocationResolutionsTable(attachedDatabase, alias); + } +} + +class LocationResolution extends DataClass + implements Insertable { + final String kind; + final String accountId; + final String sourceId; + final String itemId; + final String locationText; + final String label; + final double latitude; + final double longitude; + final String source; + final String attribution; + const LocationResolution({ + required this.kind, + required this.accountId, + required this.sourceId, + required this.itemId, + required this.locationText, + required this.label, + required this.latitude, + required this.longitude, + required this.source, + required this.attribution, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['kind'] = Variable(kind); + map['account_id'] = Variable(accountId); + map['source_id'] = Variable(sourceId); + map['item_id'] = Variable(itemId); + map['location_text'] = Variable(locationText); + map['label'] = Variable(label); + map['latitude'] = Variable(latitude); + map['longitude'] = Variable(longitude); + map['source'] = Variable(source); + map['attribution'] = Variable(attribution); + return map; + } + + LocationResolutionsCompanion toCompanion(bool nullToAbsent) { + return LocationResolutionsCompanion( + kind: Value(kind), + accountId: Value(accountId), + sourceId: Value(sourceId), + itemId: Value(itemId), + locationText: Value(locationText), + label: Value(label), + latitude: Value(latitude), + longitude: Value(longitude), + source: Value(source), + attribution: Value(attribution), + ); + } + + factory LocationResolution.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return LocationResolution( + kind: serializer.fromJson(json['kind']), + accountId: serializer.fromJson(json['accountId']), + sourceId: serializer.fromJson(json['sourceId']), + itemId: serializer.fromJson(json['itemId']), + locationText: serializer.fromJson(json['locationText']), + label: serializer.fromJson(json['label']), + latitude: serializer.fromJson(json['latitude']), + longitude: serializer.fromJson(json['longitude']), + source: serializer.fromJson(json['source']), + attribution: serializer.fromJson(json['attribution']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'kind': serializer.toJson(kind), + 'accountId': serializer.toJson(accountId), + 'sourceId': serializer.toJson(sourceId), + 'itemId': serializer.toJson(itemId), + 'locationText': serializer.toJson(locationText), + 'label': serializer.toJson(label), + 'latitude': serializer.toJson(latitude), + 'longitude': serializer.toJson(longitude), + 'source': serializer.toJson(source), + 'attribution': serializer.toJson(attribution), + }; + } + + LocationResolution copyWith({ + String? kind, + String? accountId, + String? sourceId, + String? itemId, + String? locationText, + String? label, + double? latitude, + double? longitude, + String? source, + String? attribution, + }) => LocationResolution( + kind: kind ?? this.kind, + accountId: accountId ?? this.accountId, + sourceId: sourceId ?? this.sourceId, + itemId: itemId ?? this.itemId, + locationText: locationText ?? this.locationText, + label: label ?? this.label, + latitude: latitude ?? this.latitude, + longitude: longitude ?? this.longitude, + source: source ?? this.source, + attribution: attribution ?? this.attribution, + ); + LocationResolution copyWithCompanion(LocationResolutionsCompanion data) { + return LocationResolution( + kind: data.kind.present ? data.kind.value : this.kind, + accountId: data.accountId.present ? data.accountId.value : this.accountId, + sourceId: data.sourceId.present ? data.sourceId.value : this.sourceId, + itemId: data.itemId.present ? data.itemId.value : this.itemId, + locationText: data.locationText.present + ? data.locationText.value + : this.locationText, + label: data.label.present ? data.label.value : this.label, + latitude: data.latitude.present ? data.latitude.value : this.latitude, + longitude: data.longitude.present ? data.longitude.value : this.longitude, + source: data.source.present ? data.source.value : this.source, + attribution: data.attribution.present + ? data.attribution.value + : this.attribution, + ); + } + + @override + String toString() { + return (StringBuffer('LocationResolution(') + ..write('kind: $kind, ') + ..write('accountId: $accountId, ') + ..write('sourceId: $sourceId, ') + ..write('itemId: $itemId, ') + ..write('locationText: $locationText, ') + ..write('label: $label, ') + ..write('latitude: $latitude, ') + ..write('longitude: $longitude, ') + ..write('source: $source, ') + ..write('attribution: $attribution') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash( + kind, + accountId, + sourceId, + itemId, + locationText, + label, + latitude, + longitude, + source, + attribution, + ); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is LocationResolution && + other.kind == this.kind && + other.accountId == this.accountId && + other.sourceId == this.sourceId && + other.itemId == this.itemId && + other.locationText == this.locationText && + other.label == this.label && + other.latitude == this.latitude && + other.longitude == this.longitude && + other.source == this.source && + other.attribution == this.attribution); +} + +class LocationResolutionsCompanion extends UpdateCompanion { + final Value kind; + final Value accountId; + final Value sourceId; + final Value itemId; + final Value locationText; + final Value label; + final Value latitude; + final Value longitude; + final Value source; + final Value attribution; + final Value rowid; + const LocationResolutionsCompanion({ + this.kind = const Value.absent(), + this.accountId = const Value.absent(), + this.sourceId = const Value.absent(), + this.itemId = const Value.absent(), + this.locationText = const Value.absent(), + this.label = const Value.absent(), + this.latitude = const Value.absent(), + this.longitude = const Value.absent(), + this.source = const Value.absent(), + this.attribution = const Value.absent(), + this.rowid = const Value.absent(), + }); + LocationResolutionsCompanion.insert({ + required String kind, + required String accountId, + required String sourceId, + required String itemId, + required String locationText, + required String label, + required double latitude, + required double longitude, + required String source, + required String attribution, + this.rowid = const Value.absent(), + }) : kind = Value(kind), + accountId = Value(accountId), + sourceId = Value(sourceId), + itemId = Value(itemId), + locationText = Value(locationText), + label = Value(label), + latitude = Value(latitude), + longitude = Value(longitude), + source = Value(source), + attribution = Value(attribution); + static Insertable custom({ + Expression? kind, + Expression? accountId, + Expression? sourceId, + Expression? itemId, + Expression? locationText, + Expression? label, + Expression? latitude, + Expression? longitude, + Expression? source, + Expression? attribution, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (kind != null) 'kind': kind, + if (accountId != null) 'account_id': accountId, + if (sourceId != null) 'source_id': sourceId, + if (itemId != null) 'item_id': itemId, + if (locationText != null) 'location_text': locationText, + if (label != null) 'label': label, + if (latitude != null) 'latitude': latitude, + if (longitude != null) 'longitude': longitude, + if (source != null) 'source': source, + if (attribution != null) 'attribution': attribution, + if (rowid != null) 'rowid': rowid, + }); + } + + LocationResolutionsCompanion copyWith({ + Value? kind, + Value? accountId, + Value? sourceId, + Value? itemId, + Value? locationText, + Value? label, + Value? latitude, + Value? longitude, + Value? source, + Value? attribution, + Value? rowid, + }) { + return LocationResolutionsCompanion( + kind: kind ?? this.kind, + accountId: accountId ?? this.accountId, + sourceId: sourceId ?? this.sourceId, + itemId: itemId ?? this.itemId, + locationText: locationText ?? this.locationText, + label: label ?? this.label, + latitude: latitude ?? this.latitude, + longitude: longitude ?? this.longitude, + source: source ?? this.source, + attribution: attribution ?? this.attribution, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (kind.present) { + map['kind'] = Variable(kind.value); + } + if (accountId.present) { + map['account_id'] = Variable(accountId.value); + } + if (sourceId.present) { + map['source_id'] = Variable(sourceId.value); + } + if (itemId.present) { + map['item_id'] = Variable(itemId.value); + } + if (locationText.present) { + map['location_text'] = Variable(locationText.value); + } + if (label.present) { + map['label'] = Variable(label.value); + } + if (latitude.present) { + map['latitude'] = Variable(latitude.value); + } + if (longitude.present) { + map['longitude'] = Variable(longitude.value); + } + if (source.present) { + map['source'] = Variable(source.value); + } + if (attribution.present) { + map['attribution'] = Variable(attribution.value); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('LocationResolutionsCompanion(') + ..write('kind: $kind, ') + ..write('accountId: $accountId, ') + ..write('sourceId: $sourceId, ') + ..write('itemId: $itemId, ') + ..write('locationText: $locationText, ') + ..write('label: $label, ') + ..write('latitude: $latitude, ') + ..write('longitude: $longitude, ') + ..write('source: $source, ') + ..write('attribution: $attribution, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + +abstract class _$AppDatabase extends GeneratedDatabase { + _$AppDatabase(QueryExecutor e) : super(e); + $AppDatabaseManager get managers => $AppDatabaseManager(this); + late final $AccountsTable accounts = $AccountsTable(this); + late final $DavAccountServicesTable davAccountServices = + $DavAccountServicesTable(this); + late final $DavCollectionsTable davCollections = $DavCollectionsTable(this); + late final $DavObjectsTable davObjects = $DavObjectsTable(this); + late final $DavObjectComponentsTable davObjectComponents = + $DavObjectComponentsTable(this); + late final $DavConflictSnapshotsTable davConflictSnapshots = + $DavConflictSnapshotsTable(this); + late final $TaskListsTable taskLists = $TaskListsTable(this); + late final $TasksTable tasks = $TasksTable(this); + late final $PendingOpsTable pendingOps = $PendingOpsTable(this); + late final $SyncRunsTable syncRuns = $SyncRunsTable(this); + late final $CalendarSourcesTable calendarSources = $CalendarSourcesTable( + this, + ); + late final $CalendarEventsTable calendarEvents = $CalendarEventsTable(this); + late final $IcalImportReceiptsTable icalImportReceipts = + $IcalImportReceiptsTable(this); + late final $WebCalSubscriptionsTable webCalSubscriptions = + $WebCalSubscriptionsTable(this); + late final $CalendarEventAttendeesTable calendarEventAttendees = + $CalendarEventAttendeesTable(this); + late final $CalendarEventRemindersTable calendarEventReminders = + $CalendarEventRemindersTable(this); + late final $SyncCursorsTable syncCursors = $SyncCursorsTable(this); + late final $CalendarColorsTable calendarColors = $CalendarColorsTable(this); + late final $ScheduleItemOverridesTable scheduleItemOverrides = + $ScheduleItemOverridesTable(this); + late final $NotificationScheduleTable notificationSchedule = + $NotificationScheduleTable(this); + late final $AndroidNotificationMappingsTable androidNotificationMappings = + $AndroidNotificationMappingsTable(this); + late final $AndroidDailySummarySchedulesTable androidDailySummarySchedules = + $AndroidDailySummarySchedulesTable(this); + late final $LocationResolutionsTable locationResolutions = + $LocationResolutionsTable(this); + late final TaskListsDao taskListsDao = TaskListsDao(this as AppDatabase); + late final TasksDao tasksDao = TasksDao(this as AppDatabase); + late final PendingOpsDao pendingOpsDao = PendingOpsDao(this as AppDatabase); + late final SyncRunsDao syncRunsDao = SyncRunsDao(this as AppDatabase); + @override + Iterable> get allTables => + allSchemaEntities.whereType>(); + @override + List get allSchemaEntities => [ + accounts, + davAccountServices, + davCollections, + davObjects, + davObjectComponents, + davConflictSnapshots, + taskLists, + tasks, + pendingOps, + syncRuns, + calendarSources, + calendarEvents, + icalImportReceipts, + webCalSubscriptions, + calendarEventAttendees, + calendarEventReminders, + syncCursors, + calendarColors, + scheduleItemOverrides, + notificationSchedule, + androidNotificationMappings, + androidDailySummarySchedules, + locationResolutions, + ]; + @override + StreamQueryUpdateRules get streamUpdateRules => const StreamQueryUpdateRules([ + WritePropagation( + on: TableUpdateQuery.onTableName( + 'accounts', + limitUpdateKind: UpdateKind.delete, + ), + result: [TableUpdate('dav_account_services', kind: UpdateKind.delete)], + ), + WritePropagation( + on: TableUpdateQuery.onTableName( + 'accounts', + limitUpdateKind: UpdateKind.delete, + ), + result: [TableUpdate('dav_collections', kind: UpdateKind.delete)], + ), + WritePropagation( + on: TableUpdateQuery.onTableName( + 'accounts', + limitUpdateKind: UpdateKind.delete, + ), + result: [TableUpdate('dav_objects', kind: UpdateKind.delete)], + ), + WritePropagation( + on: TableUpdateQuery.onTableName( + 'dav_collections', + limitUpdateKind: UpdateKind.delete, + ), + result: [TableUpdate('dav_objects', kind: UpdateKind.delete)], + ), + WritePropagation( + on: TableUpdateQuery.onTableName( + 'dav_objects', + limitUpdateKind: UpdateKind.delete, + ), + result: [TableUpdate('dav_object_components', kind: UpdateKind.delete)], + ), + WritePropagation( + on: TableUpdateQuery.onTableName( + 'accounts', + limitUpdateKind: UpdateKind.delete, + ), + result: [TableUpdate('dav_conflict_snapshots', kind: UpdateKind.delete)], + ), + WritePropagation( + on: TableUpdateQuery.onTableName( + 'dav_collections', + limitUpdateKind: UpdateKind.delete, + ), + result: [TableUpdate('dav_conflict_snapshots', kind: UpdateKind.update)], + ), + WritePropagation( + on: TableUpdateQuery.onTableName( + 'dav_objects', + limitUpdateKind: UpdateKind.delete, + ), + result: [TableUpdate('dav_conflict_snapshots', kind: UpdateKind.update)], + ), + WritePropagation( on: TableUpdateQuery.onTableName( 'accounts', limitUpdateKind: UpdateKind.delete, @@ -26114,6 +27058,15 @@ abstract class _$AppDatabase extends GeneratedDatabase { ), result: [TableUpdate('notification_schedule', kind: UpdateKind.delete)], ), + WritePropagation( + on: TableUpdateQuery.onTableName( + 'notification_schedule', + limitUpdateKind: UpdateKind.delete, + ), + result: [ + TableUpdate('android_notification_mappings', kind: UpdateKind.delete), + ], + ), WritePropagation( on: TableUpdateQuery.onTableName( 'accounts', @@ -26181,10 +27134,7 @@ final class $$AccountsTableReferences _davAccountServicesRefsTable(_$AppDatabase db) => MultiTypedResultKey.fromTable( db.davAccountServices, - aliasName: $_aliasNameGenerator( - db.accounts.id, - db.davAccountServices.accountId, - ), + aliasName: 'accounts__id__dav_account_services__account_id', ); $$DavAccountServicesTableProcessedTableManager get davAccountServicesRefs { @@ -26204,10 +27154,7 @@ final class $$AccountsTableReferences static MultiTypedResultKey<$DavCollectionsTable, List> _davCollectionsRefsTable(_$AppDatabase db) => MultiTypedResultKey.fromTable( db.davCollections, - aliasName: $_aliasNameGenerator( - db.accounts.id, - db.davCollections.accountId, - ), + aliasName: 'accounts__id__dav_collections__account_id', ); $$DavCollectionsTableProcessedTableManager get davCollectionsRefs { @@ -26225,7 +27172,7 @@ final class $$AccountsTableReferences static MultiTypedResultKey<$DavObjectsTable, List> _davObjectsRefsTable(_$AppDatabase db) => MultiTypedResultKey.fromTable( db.davObjects, - aliasName: $_aliasNameGenerator(db.accounts.id, db.davObjects.accountId), + aliasName: 'accounts__id__dav_objects__account_id', ); $$DavObjectsTableProcessedTableManager get davObjectsRefs { @@ -26247,10 +27194,7 @@ final class $$AccountsTableReferences _davConflictSnapshotsRefsTable(_$AppDatabase db) => MultiTypedResultKey.fromTable( db.davConflictSnapshots, - aliasName: $_aliasNameGenerator( - db.accounts.id, - db.davConflictSnapshots.accountId, - ), + aliasName: 'accounts__id__dav_conflict_snapshots__account_id', ); $$DavConflictSnapshotsTableProcessedTableManager @@ -26271,7 +27215,7 @@ final class $$AccountsTableReferences static MultiTypedResultKey<$TaskListsTable, List> _taskListsRefsTable(_$AppDatabase db) => MultiTypedResultKey.fromTable( db.taskLists, - aliasName: $_aliasNameGenerator(db.accounts.id, db.taskLists.accountId), + aliasName: 'accounts__id__task_lists__account_id', ); $$TaskListsTableProcessedTableManager get taskListsRefs { @@ -26290,7 +27234,7 @@ final class $$AccountsTableReferences _$AppDatabase db, ) => MultiTypedResultKey.fromTable( db.tasks, - aliasName: $_aliasNameGenerator(db.accounts.id, db.tasks.accountId), + aliasName: 'accounts__id__tasks__account_id', ); $$TasksTableProcessedTableManager get tasksRefs { @@ -26308,7 +27252,7 @@ final class $$AccountsTableReferences static MultiTypedResultKey<$PendingOpsTable, List> _pendingOpsRefsTable(_$AppDatabase db) => MultiTypedResultKey.fromTable( db.pendingOps, - aliasName: $_aliasNameGenerator(db.accounts.id, db.pendingOps.accountId), + aliasName: 'accounts__id__pending_ops__account_id', ); $$PendingOpsTableProcessedTableManager get pendingOpsRefs { @@ -26327,7 +27271,7 @@ final class $$AccountsTableReferences _$AppDatabase db, ) => MultiTypedResultKey.fromTable( db.syncRuns, - aliasName: $_aliasNameGenerator(db.accounts.id, db.syncRuns.accountId), + aliasName: 'accounts__id__sync_runs__account_id', ); $$SyncRunsTableProcessedTableManager get syncRunsRefs { @@ -26345,10 +27289,7 @@ final class $$AccountsTableReferences static MultiTypedResultKey<$CalendarSourcesTable, List> _calendarSourcesRefsTable(_$AppDatabase db) => MultiTypedResultKey.fromTable( db.calendarSources, - aliasName: $_aliasNameGenerator( - db.accounts.id, - db.calendarSources.accountId, - ), + aliasName: 'accounts__id__calendar_sources__account_id', ); $$CalendarSourcesTableProcessedTableManager get calendarSourcesRefs { @@ -26368,10 +27309,7 @@ final class $$AccountsTableReferences static MultiTypedResultKey<$CalendarEventsTable, List> _calendarEventsRefsTable(_$AppDatabase db) => MultiTypedResultKey.fromTable( db.calendarEvents, - aliasName: $_aliasNameGenerator( - db.accounts.id, - db.calendarEvents.accountId, - ), + aliasName: 'accounts__id__calendar_events__account_id', ); $$CalendarEventsTableProcessedTableManager get calendarEventsRefs { @@ -26393,10 +27331,7 @@ final class $$AccountsTableReferences _webCalSubscriptionsRefsTable(_$AppDatabase db) => MultiTypedResultKey.fromTable( db.webCalSubscriptions, - aliasName: $_aliasNameGenerator( - db.accounts.id, - db.webCalSubscriptions.accountId, - ), + aliasName: 'accounts__id__web_cal_subscriptions__account_id', ); $$WebCalSubscriptionsTableProcessedTableManager get webCalSubscriptionsRefs { @@ -26416,7 +27351,7 @@ final class $$AccountsTableReferences static MultiTypedResultKey<$SyncCursorsTable, List> _syncCursorsRefsTable(_$AppDatabase db) => MultiTypedResultKey.fromTable( db.syncCursors, - aliasName: $_aliasNameGenerator(db.accounts.id, db.syncCursors.accountId), + aliasName: 'accounts__id__sync_cursors__account_id', ); $$SyncCursorsTableProcessedTableManager get syncCursorsRefs { @@ -26438,10 +27373,7 @@ final class $$AccountsTableReferences _scheduleItemOverridesRefsTable(_$AppDatabase db) => MultiTypedResultKey.fromTable( db.scheduleItemOverrides, - aliasName: $_aliasNameGenerator( - db.accounts.id, - db.scheduleItemOverrides.accountId, - ), + aliasName: 'accounts__id__schedule_item_overrides__account_id', ); $$ScheduleItemOverridesTableProcessedTableManager @@ -26466,10 +27398,7 @@ final class $$AccountsTableReferences _notificationScheduleRefsTable(_$AppDatabase db) => MultiTypedResultKey.fromTable( db.notificationSchedule, - aliasName: $_aliasNameGenerator( - db.accounts.id, - db.notificationSchedule.accountId, - ), + aliasName: 'accounts__id__notification_schedule__account_id', ); $$NotificationScheduleTableProcessedTableManager @@ -26494,10 +27423,7 @@ final class $$AccountsTableReferences _locationResolutionsRefsTable(_$AppDatabase db) => MultiTypedResultKey.fromTable( db.locationResolutions, - aliasName: $_aliasNameGenerator( - db.accounts.id, - db.locationResolutions.accountId, - ), + aliasName: 'accounts__id__location_resolutions__account_id', ); $$LocationResolutionsTableProcessedTableManager get locationResolutionsRefs { @@ -27727,7 +28653,7 @@ class $$AccountsTableTableManager withReferenceMapper: (p0) => p0 .map( (e) => ( - e.readTable(table), + e.readTable<$AccountsTable, Account>(table), $$AccountsTableReferences(db, table, e), ), ) @@ -28176,9 +29102,7 @@ final class $$DavAccountServicesTableReferences ); static $AccountsTable _accountIdTable(_$AppDatabase db) => - db.accounts.createAlias( - $_aliasNameGenerator(db.davAccountServices.accountId, db.accounts.id), - ); + db.accounts.createAlias('dav_account_services__account_id__accounts__id'); $$AccountsTableProcessedTableManager get accountId { final $_column = $_itemColumn('account_id')!; @@ -28591,7 +29515,9 @@ class $$DavAccountServicesTableTableManager withReferenceMapper: (p0) => p0 .map( (e) => ( - e.readTable(table), + e.readTable<$DavAccountServicesTable, DavAccountService>( + table, + ), $$DavAccountServicesTableReferences(db, table, e), ), ) @@ -28745,9 +29671,7 @@ final class $$DavCollectionsTableReferences ); static $AccountsTable _accountIdTable(_$AppDatabase db) => - db.accounts.createAlias( - $_aliasNameGenerator(db.davCollections.accountId, db.accounts.id), - ); + db.accounts.createAlias('dav_collections__account_id__accounts__id'); $$AccountsTableProcessedTableManager get accountId { final $_column = $_itemColumn('account_id')!; @@ -28766,10 +29690,7 @@ final class $$DavCollectionsTableReferences static MultiTypedResultKey<$DavObjectsTable, List> _davObjectsRefsTable(_$AppDatabase db) => MultiTypedResultKey.fromTable( db.davObjects, - aliasName: $_aliasNameGenerator( - db.davCollections.id, - db.davObjects.collectionId, - ), + aliasName: 'dav_collections__id__dav_objects__collection_id', ); $$DavObjectsTableProcessedTableManager get davObjectsRefs { @@ -28791,10 +29712,8 @@ final class $$DavCollectionsTableReferences _davConflictSnapshotsRefsTable(_$AppDatabase db) => MultiTypedResultKey.fromTable( db.davConflictSnapshots, - aliasName: $_aliasNameGenerator( - db.davCollections.id, - db.davConflictSnapshots.davCollectionId, - ), + aliasName: + 'dav_collections__id__dav_conflict_snapshots__dav_collection_id', ); $$DavConflictSnapshotsTableProcessedTableManager @@ -28818,10 +29737,7 @@ final class $$DavCollectionsTableReferences static MultiTypedResultKey<$TaskListsTable, List> _taskListsRefsTable(_$AppDatabase db) => MultiTypedResultKey.fromTable( db.taskLists, - aliasName: $_aliasNameGenerator( - db.davCollections.id, - db.taskLists.davCollectionId, - ), + aliasName: 'dav_collections__id__task_lists__dav_collection_id', ); $$TaskListsTableProcessedTableManager get taskListsRefs { @@ -28839,10 +29755,7 @@ final class $$DavCollectionsTableReferences _$AppDatabase db, ) => MultiTypedResultKey.fromTable( db.tasks, - aliasName: $_aliasNameGenerator( - db.davCollections.id, - db.tasks.davCollectionId, - ), + aliasName: 'dav_collections__id__tasks__dav_collection_id', ); $$TasksTableProcessedTableManager get tasksRefs { @@ -28859,10 +29772,7 @@ final class $$DavCollectionsTableReferences static MultiTypedResultKey<$CalendarSourcesTable, List> _calendarSourcesRefsTable(_$AppDatabase db) => MultiTypedResultKey.fromTable( db.calendarSources, - aliasName: $_aliasNameGenerator( - db.davCollections.id, - db.calendarSources.davCollectionId, - ), + aliasName: 'dav_collections__id__calendar_sources__dav_collection_id', ); $$CalendarSourcesTableProcessedTableManager get calendarSourcesRefs { @@ -28882,10 +29792,7 @@ final class $$DavCollectionsTableReferences static MultiTypedResultKey<$CalendarEventsTable, List> _calendarEventsRefsTable(_$AppDatabase db) => MultiTypedResultKey.fromTable( db.calendarEvents, - aliasName: $_aliasNameGenerator( - db.davCollections.id, - db.calendarEvents.davCollectionId, - ), + aliasName: 'dav_collections__id__calendar_events__dav_collection_id', ); $$CalendarEventsTableProcessedTableManager get calendarEventsRefs { @@ -28903,10 +29810,7 @@ final class $$DavCollectionsTableReferences static MultiTypedResultKey<$SyncCursorsTable, List> _syncCursorsRefsTable(_$AppDatabase db) => MultiTypedResultKey.fromTable( db.syncCursors, - aliasName: $_aliasNameGenerator( - db.davCollections.id, - db.syncCursors.davCollectionId, - ), + aliasName: 'dav_collections__id__sync_cursors__dav_collection_id', ); $$SyncCursorsTableProcessedTableManager get syncCursorsRefs { @@ -30056,7 +30960,7 @@ class $$DavCollectionsTableTableManager withReferenceMapper: (p0) => p0 .map( (e) => ( - e.readTable(table), + e.readTable<$DavCollectionsTable, DavCollection>(table), $$DavCollectionsTableReferences(db, table, e), ), ) @@ -30353,9 +31257,7 @@ final class $$DavObjectsTableReferences $$DavObjectsTableReferences(super.$_db, super.$_table, super.$_typedResult); static $AccountsTable _accountIdTable(_$AppDatabase db) => - db.accounts.createAlias( - $_aliasNameGenerator(db.davObjects.accountId, db.accounts.id), - ); + db.accounts.createAlias('dav_objects__account_id__accounts__id'); $$AccountsTableProcessedTableManager get accountId { final $_column = $_itemColumn('account_id')!; @@ -30371,10 +31273,9 @@ final class $$DavObjectsTableReferences ); } - static $DavCollectionsTable _collectionIdTable(_$AppDatabase db) => - db.davCollections.createAlias( - $_aliasNameGenerator(db.davObjects.collectionId, db.davCollections.id), - ); + static $DavCollectionsTable _collectionIdTable(_$AppDatabase db) => db + .davCollections + .createAlias('dav_objects__collection_id__dav_collections__id'); $$DavCollectionsTableProcessedTableManager get collectionId { final $_column = $_itemColumn('collection_id')!; @@ -30397,10 +31298,7 @@ final class $$DavObjectsTableReferences _davObjectComponentsRefsTable(_$AppDatabase db) => MultiTypedResultKey.fromTable( db.davObjectComponents, - aliasName: $_aliasNameGenerator( - db.davObjects.id, - db.davObjectComponents.davObjectId, - ), + aliasName: 'dav_objects__id__dav_object_components__dav_object_id', ); $$DavObjectComponentsTableProcessedTableManager get davObjectComponentsRefs { @@ -30424,10 +31322,7 @@ final class $$DavObjectsTableReferences _davConflictSnapshotsRefsTable(_$AppDatabase db) => MultiTypedResultKey.fromTable( db.davConflictSnapshots, - aliasName: $_aliasNameGenerator( - db.davObjects.id, - db.davConflictSnapshots.davObjectId, - ), + aliasName: 'dav_objects__id__dav_conflict_snapshots__dav_object_id', ); $$DavConflictSnapshotsTableProcessedTableManager @@ -30449,7 +31344,7 @@ final class $$DavObjectsTableReferences _$AppDatabase db, ) => MultiTypedResultKey.fromTable( db.tasks, - aliasName: $_aliasNameGenerator(db.davObjects.id, db.tasks.davObjectId), + aliasName: 'dav_objects__id__tasks__dav_object_id', ); $$TasksTableProcessedTableManager get tasksRefs { @@ -30467,10 +31362,7 @@ final class $$DavObjectsTableReferences static MultiTypedResultKey<$PendingOpsTable, List> _pendingOpsRefsTable(_$AppDatabase db) => MultiTypedResultKey.fromTable( db.pendingOps, - aliasName: $_aliasNameGenerator( - db.davObjects.id, - db.pendingOps.davObjectId, - ), + aliasName: 'dav_objects__id__pending_ops__dav_object_id', ); $$PendingOpsTableProcessedTableManager get pendingOpsRefs { @@ -30488,10 +31380,7 @@ final class $$DavObjectsTableReferences static MultiTypedResultKey<$CalendarEventsTable, List> _calendarEventsRefsTable(_$AppDatabase db) => MultiTypedResultKey.fromTable( db.calendarEvents, - aliasName: $_aliasNameGenerator( - db.davObjects.id, - db.calendarEvents.davObjectId, - ), + aliasName: 'dav_objects__id__calendar_events__dav_object_id', ); $$CalendarEventsTableProcessedTableManager get calendarEventsRefs { @@ -31339,7 +32228,7 @@ class $$DavObjectsTableTableManager withReferenceMapper: (p0) => p0 .map( (e) => ( - e.readTable(table), + e.readTable<$DavObjectsTable, DavObject>(table), $$DavObjectsTableReferences(db, table, e), ), ) @@ -31589,13 +32478,8 @@ final class $$DavObjectComponentsTableReferences super.$_typedResult, ); - static $DavObjectsTable _davObjectIdTable(_$AppDatabase db) => - db.davObjects.createAlias( - $_aliasNameGenerator( - db.davObjectComponents.davObjectId, - db.davObjects.id, - ), - ); + static $DavObjectsTable _davObjectIdTable(_$AppDatabase db) => db.davObjects + .createAlias('dav_object_components__dav_object_id__dav_objects__id'); $$DavObjectsTableProcessedTableManager get davObjectId { final $_column = $_itemColumn('dav_object_id')!; @@ -31615,10 +32499,7 @@ final class $$DavObjectComponentsTableReferences _$AppDatabase db, ) => MultiTypedResultKey.fromTable( db.tasks, - aliasName: $_aliasNameGenerator( - db.davObjectComponents.id, - db.tasks.davComponentId, - ), + aliasName: 'dav_object_components__id__tasks__dav_component_id', ); $$TasksTableProcessedTableManager get tasksRefs { @@ -31636,10 +32517,7 @@ final class $$DavObjectComponentsTableReferences static MultiTypedResultKey<$CalendarEventsTable, List> _calendarEventsRefsTable(_$AppDatabase db) => MultiTypedResultKey.fromTable( db.calendarEvents, - aliasName: $_aliasNameGenerator( - db.davObjectComponents.id, - db.calendarEvents.davComponentId, - ), + aliasName: 'dav_object_components__id__calendar_events__dav_component_id', ); $$CalendarEventsTableProcessedTableManager get calendarEventsRefs { @@ -32076,7 +32954,9 @@ class $$DavObjectComponentsTableTableManager withReferenceMapper: (p0) => p0 .map( (e) => ( - e.readTable(table), + e.readTable<$DavObjectComponentsTable, DavObjectComponent>( + table, + ), $$DavObjectComponentsTableReferences(db, table, e), ), ) @@ -32245,10 +33125,8 @@ final class $$DavConflictSnapshotsTableReferences super.$_typedResult, ); - static $AccountsTable _accountIdTable(_$AppDatabase db) => - db.accounts.createAlias( - $_aliasNameGenerator(db.davConflictSnapshots.accountId, db.accounts.id), - ); + static $AccountsTable _accountIdTable(_$AppDatabase db) => db.accounts + .createAlias('dav_conflict_snapshots__account_id__accounts__id'); $$AccountsTableProcessedTableManager get accountId { final $_column = $_itemColumn('account_id')!; @@ -32266,10 +33144,7 @@ final class $$DavConflictSnapshotsTableReferences static $DavCollectionsTable _davCollectionIdTable(_$AppDatabase db) => db.davCollections.createAlias( - $_aliasNameGenerator( - db.davConflictSnapshots.davCollectionId, - db.davCollections.id, - ), + 'dav_conflict_snapshots__dav_collection_id__dav_collections__id', ); $$DavCollectionsTableProcessedTableManager? get davCollectionId { @@ -32286,13 +33161,8 @@ final class $$DavConflictSnapshotsTableReferences ); } - static $DavObjectsTable _davObjectIdTable(_$AppDatabase db) => - db.davObjects.createAlias( - $_aliasNameGenerator( - db.davConflictSnapshots.davObjectId, - db.davObjects.id, - ), - ); + static $DavObjectsTable _davObjectIdTable(_$AppDatabase db) => db.davObjects + .createAlias('dav_conflict_snapshots__dav_object_id__dav_objects__id'); $$DavObjectsTableProcessedTableManager? get davObjectId { final $_column = $_itemColumn('dav_object_id'); @@ -32311,10 +33181,7 @@ final class $$DavConflictSnapshotsTableReferences static MultiTypedResultKey<$PendingOpsTable, List> _pendingOpsRefsTable(_$AppDatabase db) => MultiTypedResultKey.fromTable( db.pendingOps, - aliasName: $_aliasNameGenerator( - db.davConflictSnapshots.id, - db.pendingOps.conflictSnapshotId, - ), + aliasName: 'dav_conflict_snapshots__id__pending_ops__conflict_snapshot_id', ); $$PendingOpsTableProcessedTableManager get pendingOpsRefs { @@ -32870,7 +33737,9 @@ class $$DavConflictSnapshotsTableTableManager withReferenceMapper: (p0) => p0 .map( (e) => ( - e.readTable(table), + e.readTable<$DavConflictSnapshotsTable, DavConflictSnapshot>( + table, + ), $$DavConflictSnapshotsTableReferences(db, table, e), ), ) @@ -33055,9 +33924,7 @@ final class $$TaskListsTableReferences $$TaskListsTableReferences(super.$_db, super.$_table, super.$_typedResult); static $AccountsTable _accountIdTable(_$AppDatabase db) => - db.accounts.createAlias( - $_aliasNameGenerator(db.taskLists.accountId, db.accounts.id), - ); + db.accounts.createAlias('task_lists__account_id__accounts__id'); $$AccountsTableProcessedTableManager get accountId { final $_column = $_itemColumn('account_id')!; @@ -33073,13 +33940,9 @@ final class $$TaskListsTableReferences ); } - static $DavCollectionsTable _davCollectionIdTable(_$AppDatabase db) => - db.davCollections.createAlias( - $_aliasNameGenerator( - db.taskLists.davCollectionId, - db.davCollections.id, - ), - ); + static $DavCollectionsTable _davCollectionIdTable(_$AppDatabase db) => db + .davCollections + .createAlias('task_lists__dav_collection_id__dav_collections__id'); $$DavCollectionsTableProcessedTableManager? get davCollectionId { final $_column = $_itemColumn('dav_collection_id'); @@ -33656,7 +34519,7 @@ class $$TaskListsTableTableManager withReferenceMapper: (p0) => p0 .map( (e) => ( - e.readTable(table), + e.readTable<$TaskListsTable, TaskList>(table), $$TaskListsTableReferences(db, table, e), ), ) @@ -33881,8 +34744,8 @@ final class $$TasksTableReferences extends BaseReferences<_$AppDatabase, $TasksTable, Task> { $$TasksTableReferences(super.$_db, super.$_table, super.$_typedResult); - static $AccountsTable _accountIdTable(_$AppDatabase db) => db.accounts - .createAlias($_aliasNameGenerator(db.tasks.accountId, db.accounts.id)); + static $AccountsTable _accountIdTable(_$AppDatabase db) => + db.accounts.createAlias('tasks__account_id__accounts__id'); $$AccountsTableProcessedTableManager get accountId { final $_column = $_itemColumn('account_id')!; @@ -33898,10 +34761,9 @@ final class $$TasksTableReferences ); } - static $DavCollectionsTable _davCollectionIdTable(_$AppDatabase db) => - db.davCollections.createAlias( - $_aliasNameGenerator(db.tasks.davCollectionId, db.davCollections.id), - ); + static $DavCollectionsTable _davCollectionIdTable(_$AppDatabase db) => db + .davCollections + .createAlias('tasks__dav_collection_id__dav_collections__id'); $$DavCollectionsTableProcessedTableManager? get davCollectionId { final $_column = $_itemColumn('dav_collection_id'); @@ -33918,9 +34780,7 @@ final class $$TasksTableReferences } static $DavObjectsTable _davObjectIdTable(_$AppDatabase db) => - db.davObjects.createAlias( - $_aliasNameGenerator(db.tasks.davObjectId, db.davObjects.id), - ); + db.davObjects.createAlias('tasks__dav_object_id__dav_objects__id'); $$DavObjectsTableProcessedTableManager? get davObjectId { final $_column = $_itemColumn('dav_object_id'); @@ -33936,13 +34796,9 @@ final class $$TasksTableReferences ); } - static $DavObjectComponentsTable _davComponentIdTable(_$AppDatabase db) => - db.davObjectComponents.createAlias( - $_aliasNameGenerator( - db.tasks.davComponentId, - db.davObjectComponents.id, - ), - ); + static $DavObjectComponentsTable _davComponentIdTable(_$AppDatabase db) => db + .davObjectComponents + .createAlias('tasks__dav_component_id__dav_object_components__id'); $$DavObjectComponentsTableProcessedTableManager? get davComponentId { final $_column = $_itemColumn('dav_component_id'); @@ -35501,8 +36357,10 @@ class $$TasksTableTableManager ), withReferenceMapper: (p0) => p0 .map( - (e) => - (e.readTable(table), $$TasksTableReferences(db, table, e)), + (e) => ( + e.readTable<$TasksTable, Task>(table), + $$TasksTableReferences(db, table, e), + ), ) .toList(), prefetchHooksCallback: @@ -35708,9 +36566,7 @@ final class $$PendingOpsTableReferences $$PendingOpsTableReferences(super.$_db, super.$_table, super.$_typedResult); static $AccountsTable _accountIdTable(_$AppDatabase db) => - db.accounts.createAlias( - $_aliasNameGenerator(db.pendingOps.accountId, db.accounts.id), - ); + db.accounts.createAlias('pending_ops__account_id__accounts__id'); $$AccountsTableProcessedTableManager get accountId { final $_column = $_itemColumn('account_id')!; @@ -35726,13 +36582,9 @@ final class $$PendingOpsTableReferences ); } - static $DavCollectionsTable _davCollectionIdTable(_$AppDatabase db) => - db.davCollections.createAlias( - $_aliasNameGenerator( - db.pendingOps.davCollectionId, - db.davCollections.id, - ), - ); + static $DavCollectionsTable _davCollectionIdTable(_$AppDatabase db) => db + .davCollections + .createAlias('pending_ops__dav_collection_id__dav_collections__id'); $$DavCollectionsTableProcessedTableManager? get davCollectionId { final $_column = $_itemColumn('dav_collection_id'); @@ -35749,9 +36601,7 @@ final class $$PendingOpsTableReferences } static $DavObjectsTable _davObjectIdTable(_$AppDatabase db) => - db.davObjects.createAlias( - $_aliasNameGenerator(db.pendingOps.davObjectId, db.davObjects.id), - ); + db.davObjects.createAlias('pending_ops__dav_object_id__dav_objects__id'); $$DavObjectsTableProcessedTableManager? get davObjectId { final $_column = $_itemColumn('dav_object_id'); @@ -35769,10 +36619,7 @@ final class $$PendingOpsTableReferences static $DavCollectionsTable _destinationCollectionIdTable(_$AppDatabase db) => db.davCollections.createAlias( - $_aliasNameGenerator( - db.pendingOps.destinationCollectionId, - db.davCollections.id, - ), + 'pending_ops__destination_collection_id__dav_collections__id', ); $$DavCollectionsTableProcessedTableManager? get destinationCollectionId { @@ -35794,10 +36641,7 @@ final class $$PendingOpsTableReferences static $DavConflictSnapshotsTable _conflictSnapshotIdTable( _$AppDatabase db, ) => db.davConflictSnapshots.createAlias( - $_aliasNameGenerator( - db.pendingOps.conflictSnapshotId, - db.davConflictSnapshots.id, - ), + 'pending_ops__conflict_snapshot_id__dav_conflict_snapshots__id', ); $$DavConflictSnapshotsTableProcessedTableManager? get conflictSnapshotId { @@ -36910,7 +37754,7 @@ class $$PendingOpsTableTableManager withReferenceMapper: (p0) => p0 .map( (e) => ( - e.readTable(table), + e.readTable<$PendingOpsTable, PendingOp>(table), $$PendingOpsTableReferences(db, table, e), ), ) @@ -37082,8 +37926,8 @@ final class $$SyncRunsTableReferences extends BaseReferences<_$AppDatabase, $SyncRunsTable, SyncRun> { $$SyncRunsTableReferences(super.$_db, super.$_table, super.$_typedResult); - static $AccountsTable _accountIdTable(_$AppDatabase db) => db.accounts - .createAlias($_aliasNameGenerator(db.syncRuns.accountId, db.accounts.id)); + static $AccountsTable _accountIdTable(_$AppDatabase db) => + db.accounts.createAlias('sync_runs__account_id__accounts__id'); $$AccountsTableProcessedTableManager get accountId { final $_column = $_itemColumn('account_id')!; @@ -37441,7 +38285,7 @@ class $$SyncRunsTableTableManager withReferenceMapper: (p0) => p0 .map( (e) => ( - e.readTable(table), + e.readTable<$SyncRunsTable, SyncRun>(table), $$SyncRunsTableReferences(db, table, e), ), ) @@ -37570,9 +38414,7 @@ final class $$CalendarSourcesTableReferences ); static $AccountsTable _accountIdTable(_$AppDatabase db) => - db.accounts.createAlias( - $_aliasNameGenerator(db.calendarSources.accountId, db.accounts.id), - ); + db.accounts.createAlias('calendar_sources__account_id__accounts__id'); $$AccountsTableProcessedTableManager get accountId { final $_column = $_itemColumn('account_id')!; @@ -37588,13 +38430,9 @@ final class $$CalendarSourcesTableReferences ); } - static $DavCollectionsTable _davCollectionIdTable(_$AppDatabase db) => - db.davCollections.createAlias( - $_aliasNameGenerator( - db.calendarSources.davCollectionId, - db.davCollections.id, - ), - ); + static $DavCollectionsTable _davCollectionIdTable(_$AppDatabase db) => db + .davCollections + .createAlias('calendar_sources__dav_collection_id__dav_collections__id'); $$DavCollectionsTableProcessedTableManager? get davCollectionId { final $_column = $_itemColumn('dav_collection_id'); @@ -37613,10 +38451,7 @@ final class $$CalendarSourcesTableReferences static MultiTypedResultKey<$CalendarEventsTable, List> _calendarEventsRefsTable(_$AppDatabase db) => MultiTypedResultKey.fromTable( db.calendarEvents, - aliasName: $_aliasNameGenerator( - db.calendarSources.id, - db.calendarEvents.calendarSourceId, - ), + aliasName: 'calendar_sources__id__calendar_events__calendar_source_id', ); $$CalendarEventsTableProcessedTableManager get calendarEventsRefs { @@ -37635,10 +38470,8 @@ final class $$CalendarSourcesTableReferences _icalImportReceiptsRefsTable(_$AppDatabase db) => MultiTypedResultKey.fromTable( db.icalImportReceipts, - aliasName: $_aliasNameGenerator( - db.calendarSources.id, - db.icalImportReceipts.calendarSourceId, - ), + aliasName: + 'calendar_sources__id__ical_import_receipts__calendar_source_id', ); $$IcalImportReceiptsTableProcessedTableManager get icalImportReceiptsRefs { @@ -37665,10 +38498,8 @@ final class $$CalendarSourcesTableReferences _webCalSubscriptionsRefsTable(_$AppDatabase db) => MultiTypedResultKey.fromTable( db.webCalSubscriptions, - aliasName: $_aliasNameGenerator( - db.calendarSources.id, - db.webCalSubscriptions.calendarSourceId, - ), + aliasName: + 'calendar_sources__id__web_cal_subscriptions__calendar_source_id', ); $$WebCalSubscriptionsTableProcessedTableManager get webCalSubscriptionsRefs { @@ -37691,10 +38522,7 @@ final class $$CalendarSourcesTableReferences static MultiTypedResultKey<$SyncCursorsTable, List> _syncCursorsRefsTable(_$AppDatabase db) => MultiTypedResultKey.fromTable( db.syncCursors, - aliasName: $_aliasNameGenerator( - db.calendarSources.id, - db.syncCursors.projectionSourceId, - ), + aliasName: 'calendar_sources__id__sync_cursors__projection_source_id', ); $$SyncCursorsTableProcessedTableManager get syncCursorsRefs { @@ -38515,7 +39343,7 @@ class $$CalendarSourcesTableTableManager withReferenceMapper: (p0) => p0 .map( (e) => ( - e.readTable(table), + e.readTable<$CalendarSourcesTable, CalendarSource>(table), $$CalendarSourcesTableReferences(db, table, e), ), ) @@ -38823,9 +39651,7 @@ final class $$CalendarEventsTableReferences ); static $AccountsTable _accountIdTable(_$AppDatabase db) => - db.accounts.createAlias( - $_aliasNameGenerator(db.calendarEvents.accountId, db.accounts.id), - ); + db.accounts.createAlias('calendar_events__account_id__accounts__id'); $$AccountsTableProcessedTableManager get accountId { final $_column = $_itemColumn('account_id')!; @@ -38841,13 +39667,9 @@ final class $$CalendarEventsTableReferences ); } - static $CalendarSourcesTable _calendarSourceIdTable(_$AppDatabase db) => - db.calendarSources.createAlias( - $_aliasNameGenerator( - db.calendarEvents.calendarSourceId, - db.calendarSources.id, - ), - ); + static $CalendarSourcesTable _calendarSourceIdTable(_$AppDatabase db) => db + .calendarSources + .createAlias('calendar_events__calendar_source_id__calendar_sources__id'); $$CalendarSourcesTableProcessedTableManager get calendarSourceId { final $_column = $_itemColumn('calendar_source_id')!; @@ -38863,13 +39685,9 @@ final class $$CalendarEventsTableReferences ); } - static $DavCollectionsTable _davCollectionIdTable(_$AppDatabase db) => - db.davCollections.createAlias( - $_aliasNameGenerator( - db.calendarEvents.davCollectionId, - db.davCollections.id, - ), - ); + static $DavCollectionsTable _davCollectionIdTable(_$AppDatabase db) => db + .davCollections + .createAlias('calendar_events__dav_collection_id__dav_collections__id'); $$DavCollectionsTableProcessedTableManager? get davCollectionId { final $_column = $_itemColumn('dav_collection_id'); @@ -38885,10 +39703,8 @@ final class $$CalendarEventsTableReferences ); } - static $DavObjectsTable _davObjectIdTable(_$AppDatabase db) => - db.davObjects.createAlias( - $_aliasNameGenerator(db.calendarEvents.davObjectId, db.davObjects.id), - ); + static $DavObjectsTable _davObjectIdTable(_$AppDatabase db) => db.davObjects + .createAlias('calendar_events__dav_object_id__dav_objects__id'); $$DavObjectsTableProcessedTableManager? get davObjectId { final $_column = $_itemColumn('dav_object_id'); @@ -38906,10 +39722,7 @@ final class $$CalendarEventsTableReferences static $DavObjectComponentsTable _davComponentIdTable(_$AppDatabase db) => db.davObjectComponents.createAlias( - $_aliasNameGenerator( - db.calendarEvents.davComponentId, - db.davObjectComponents.id, - ), + 'calendar_events__dav_component_id__dav_object_components__id', ); $$DavObjectComponentsTableProcessedTableManager? get davComponentId { @@ -38933,10 +39746,8 @@ final class $$CalendarEventsTableReferences _calendarEventAttendeesRefsTable(_$AppDatabase db) => MultiTypedResultKey.fromTable( db.calendarEventAttendees, - aliasName: $_aliasNameGenerator( - db.calendarEvents.id, - db.calendarEventAttendees.calendarEventId, - ), + aliasName: + 'calendar_events__id__calendar_event_attendees__calendar_event_id', ); $$CalendarEventAttendeesTableProcessedTableManager @@ -38964,10 +39775,8 @@ final class $$CalendarEventsTableReferences _calendarEventRemindersRefsTable(_$AppDatabase db) => MultiTypedResultKey.fromTable( db.calendarEventReminders, - aliasName: $_aliasNameGenerator( - db.calendarEvents.id, - db.calendarEventReminders.calendarEventId, - ), + aliasName: + 'calendar_events__id__calendar_event_reminders__calendar_event_id', ); $$CalendarEventRemindersTableProcessedTableManager @@ -39903,93 +40712,784 @@ class $$CalendarEventsTableAnnotationComposer builder: (column) => column, ); - GeneratedColumn get colorId => - $composableBuilder(column: $table.colorId, builder: (column) => column); + GeneratedColumn get colorId => + $composableBuilder(column: $table.colorId, builder: (column) => column); + + GeneratedColumn get colorHex => + $composableBuilder(column: $table.colorHex, builder: (column) => column); + + GeneratedColumn get visibility => $composableBuilder( + column: $table.visibility, + builder: (column) => column, + ); + + GeneratedColumn get transparencyOrShowAs => $composableBuilder( + column: $table.transparencyOrShowAs, + builder: (column) => column, + ); + + GeneratedColumn get eventType => + $composableBuilder(column: $table.eventType, builder: (column) => column); + + GeneratedColumn get webLink => + $composableBuilder(column: $table.webLink, builder: (column) => column); + + GeneratedColumn get conferenceJson => $composableBuilder( + column: $table.conferenceJson, + builder: (column) => column, + ); + + GeneratedColumn get attachmentsJson => $composableBuilder( + column: $table.attachmentsJson, + builder: (column) => column, + ); + + GeneratedColumn get isCancelled => $composableBuilder( + column: $table.isCancelled, + builder: (column) => column, + ); + + GeneratedColumn get isDeleted => + $composableBuilder(column: $table.isDeleted, builder: (column) => column); + + GeneratedColumn get rawJson => + $composableBuilder(column: $table.rawJson, builder: (column) => column); + + GeneratedColumn get createdAtServer => $composableBuilder( + column: $table.createdAtServer, + builder: (column) => column, + ); + + GeneratedColumn get updatedAtServer => $composableBuilder( + column: $table.updatedAtServer, + builder: (column) => column, + ); + + GeneratedColumn get createdAtLocal => $composableBuilder( + column: $table.createdAtLocal, + builder: (column) => column, + ); + + GeneratedColumn get updatedAtLocal => $composableBuilder( + column: $table.updatedAtLocal, + builder: (column) => column, + ); + + GeneratedColumn get syncStatus => $composableBuilder( + column: $table.syncStatus, + builder: (column) => column, + ); + + GeneratedColumn get baselineRawJson => $composableBuilder( + column: $table.baselineRawJson, + builder: (column) => column, + ); + + $$AccountsTableAnnotationComposer get accountId { + final $$AccountsTableAnnotationComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.accountId, + referencedTable: $db.accounts, + getReferencedColumn: (t) => t.id, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$AccountsTableAnnotationComposer( + $db: $db, + $table: $db.accounts, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return composer; + } + + $$CalendarSourcesTableAnnotationComposer get calendarSourceId { + final $$CalendarSourcesTableAnnotationComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.calendarSourceId, + referencedTable: $db.calendarSources, + getReferencedColumn: (t) => t.id, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$CalendarSourcesTableAnnotationComposer( + $db: $db, + $table: $db.calendarSources, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return composer; + } - GeneratedColumn get colorHex => - $composableBuilder(column: $table.colorHex, builder: (column) => column); + $$DavCollectionsTableAnnotationComposer get davCollectionId { + final $$DavCollectionsTableAnnotationComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.davCollectionId, + referencedTable: $db.davCollections, + getReferencedColumn: (t) => t.id, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$DavCollectionsTableAnnotationComposer( + $db: $db, + $table: $db.davCollections, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return composer; + } - GeneratedColumn get visibility => $composableBuilder( - column: $table.visibility, - builder: (column) => column, - ); + $$DavObjectsTableAnnotationComposer get davObjectId { + final $$DavObjectsTableAnnotationComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.davObjectId, + referencedTable: $db.davObjects, + getReferencedColumn: (t) => t.id, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$DavObjectsTableAnnotationComposer( + $db: $db, + $table: $db.davObjects, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return composer; + } - GeneratedColumn get transparencyOrShowAs => $composableBuilder( - column: $table.transparencyOrShowAs, - builder: (column) => column, - ); + $$DavObjectComponentsTableAnnotationComposer get davComponentId { + final $$DavObjectComponentsTableAnnotationComposer composer = + $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.davComponentId, + referencedTable: $db.davObjectComponents, + getReferencedColumn: (t) => t.id, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$DavObjectComponentsTableAnnotationComposer( + $db: $db, + $table: $db.davObjectComponents, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return composer; + } - GeneratedColumn get eventType => - $composableBuilder(column: $table.eventType, builder: (column) => column); + Expression calendarEventAttendeesRefs( + Expression Function($$CalendarEventAttendeesTableAnnotationComposer a) f, + ) { + final $$CalendarEventAttendeesTableAnnotationComposer composer = + $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.id, + referencedTable: $db.calendarEventAttendees, + getReferencedColumn: (t) => t.calendarEventId, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$CalendarEventAttendeesTableAnnotationComposer( + $db: $db, + $table: $db.calendarEventAttendees, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return f(composer); + } - GeneratedColumn get webLink => - $composableBuilder(column: $table.webLink, builder: (column) => column); + Expression calendarEventRemindersRefs( + Expression Function($$CalendarEventRemindersTableAnnotationComposer a) f, + ) { + final $$CalendarEventRemindersTableAnnotationComposer composer = + $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.id, + referencedTable: $db.calendarEventReminders, + getReferencedColumn: (t) => t.calendarEventId, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$CalendarEventRemindersTableAnnotationComposer( + $db: $db, + $table: $db.calendarEventReminders, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return f(composer); + } +} + +class $$CalendarEventsTableTableManager + extends + RootTableManager< + _$AppDatabase, + $CalendarEventsTable, + CalendarEvent, + $$CalendarEventsTableFilterComposer, + $$CalendarEventsTableOrderingComposer, + $$CalendarEventsTableAnnotationComposer, + $$CalendarEventsTableCreateCompanionBuilder, + $$CalendarEventsTableUpdateCompanionBuilder, + (CalendarEvent, $$CalendarEventsTableReferences), + CalendarEvent, + PrefetchHooks Function({ + bool accountId, + bool calendarSourceId, + bool davCollectionId, + bool davObjectId, + bool davComponentId, + bool calendarEventAttendeesRefs, + bool calendarEventRemindersRefs, + }) + > { + $$CalendarEventsTableTableManager( + _$AppDatabase db, + $CalendarEventsTable table, + ) : super( + TableManagerState( + db: db, + table: table, + createFilteringComposer: () => + $$CalendarEventsTableFilterComposer($db: db, $table: table), + createOrderingComposer: () => + $$CalendarEventsTableOrderingComposer($db: db, $table: table), + createComputedFieldComposer: () => + $$CalendarEventsTableAnnotationComposer($db: db, $table: table), + updateCompanionCallback: + ({ + Value id = const Value.absent(), + Value accountId = const Value.absent(), + Value calendarSourceId = const Value.absent(), + Value provider = const Value.absent(), + Value providerCalendarId = const Value.absent(), + Value providerEventId = const Value.absent(), + Value davCollectionId = const Value.absent(), + Value davObjectId = const Value.absent(), + Value davComponentId = const Value.absent(), + Value icalUid = const Value.absent(), + Value recurrenceIdKey = const Value.absent(), + Value occurrenceKey = const Value.absent(), + Value projectionVersion = const Value.absent(), + Value providerRecurringEventId = const Value.absent(), + Value providerOriginalStartKey = const Value.absent(), + Value etagOrChangeKey = const Value.absent(), + Value status = const Value.absent(), + Value title = const Value.absent(), + Value description = const Value.absent(), + Value location = const Value.absent(), + Value locationLatitude = const Value.absent(), + Value locationLongitude = const Value.absent(), + Value allDay = const Value.absent(), + Value startDate = const Value.absent(), + Value startDateTime = const Value.absent(), + Value startTimeZone = const Value.absent(), + Value endDate = const Value.absent(), + Value endDateTime = const Value.absent(), + Value endTimeZone = const Value.absent(), + Value recurrenceJson = const Value.absent(), + Value remindersJson = const Value.absent(), + Value attendeesJson = const Value.absent(), + Value categoriesJson = const Value.absent(), + Value organizerJson = const Value.absent(), + Value creatorJson = const Value.absent(), + Value colorId = const Value.absent(), + Value colorHex = const Value.absent(), + Value visibility = const Value.absent(), + Value transparencyOrShowAs = const Value.absent(), + Value eventType = const Value.absent(), + Value webLink = const Value.absent(), + Value conferenceJson = const Value.absent(), + Value attachmentsJson = const Value.absent(), + Value isCancelled = const Value.absent(), + Value isDeleted = const Value.absent(), + Value rawJson = const Value.absent(), + Value createdAtServer = const Value.absent(), + Value updatedAtServer = const Value.absent(), + Value createdAtLocal = const Value.absent(), + Value updatedAtLocal = const Value.absent(), + Value syncStatus = const Value.absent(), + Value baselineRawJson = const Value.absent(), + Value rowid = const Value.absent(), + }) => CalendarEventsCompanion( + id: id, + accountId: accountId, + calendarSourceId: calendarSourceId, + provider: provider, + providerCalendarId: providerCalendarId, + providerEventId: providerEventId, + davCollectionId: davCollectionId, + davObjectId: davObjectId, + davComponentId: davComponentId, + icalUid: icalUid, + recurrenceIdKey: recurrenceIdKey, + occurrenceKey: occurrenceKey, + projectionVersion: projectionVersion, + providerRecurringEventId: providerRecurringEventId, + providerOriginalStartKey: providerOriginalStartKey, + etagOrChangeKey: etagOrChangeKey, + status: status, + title: title, + description: description, + location: location, + locationLatitude: locationLatitude, + locationLongitude: locationLongitude, + allDay: allDay, + startDate: startDate, + startDateTime: startDateTime, + startTimeZone: startTimeZone, + endDate: endDate, + endDateTime: endDateTime, + endTimeZone: endTimeZone, + recurrenceJson: recurrenceJson, + remindersJson: remindersJson, + attendeesJson: attendeesJson, + categoriesJson: categoriesJson, + organizerJson: organizerJson, + creatorJson: creatorJson, + colorId: colorId, + colorHex: colorHex, + visibility: visibility, + transparencyOrShowAs: transparencyOrShowAs, + eventType: eventType, + webLink: webLink, + conferenceJson: conferenceJson, + attachmentsJson: attachmentsJson, + isCancelled: isCancelled, + isDeleted: isDeleted, + rawJson: rawJson, + createdAtServer: createdAtServer, + updatedAtServer: updatedAtServer, + createdAtLocal: createdAtLocal, + updatedAtLocal: updatedAtLocal, + syncStatus: syncStatus, + baselineRawJson: baselineRawJson, + rowid: rowid, + ), + createCompanionCallback: + ({ + required String id, + required String accountId, + required String calendarSourceId, + required String provider, + required String providerCalendarId, + required String providerEventId, + Value davCollectionId = const Value.absent(), + Value davObjectId = const Value.absent(), + Value davComponentId = const Value.absent(), + Value icalUid = const Value.absent(), + Value recurrenceIdKey = const Value.absent(), + Value occurrenceKey = const Value.absent(), + Value projectionVersion = const Value.absent(), + Value providerRecurringEventId = const Value.absent(), + Value providerOriginalStartKey = const Value.absent(), + Value etagOrChangeKey = const Value.absent(), + Value status = const Value.absent(), + required String title, + Value description = const Value.absent(), + Value location = const Value.absent(), + Value locationLatitude = const Value.absent(), + Value locationLongitude = const Value.absent(), + Value allDay = const Value.absent(), + Value startDate = const Value.absent(), + Value startDateTime = const Value.absent(), + Value startTimeZone = const Value.absent(), + Value endDate = const Value.absent(), + Value endDateTime = const Value.absent(), + Value endTimeZone = const Value.absent(), + Value recurrenceJson = const Value.absent(), + Value remindersJson = const Value.absent(), + Value attendeesJson = const Value.absent(), + Value categoriesJson = const Value.absent(), + Value organizerJson = const Value.absent(), + Value creatorJson = const Value.absent(), + Value colorId = const Value.absent(), + Value colorHex = const Value.absent(), + Value visibility = const Value.absent(), + Value transparencyOrShowAs = const Value.absent(), + Value eventType = const Value.absent(), + Value webLink = const Value.absent(), + Value conferenceJson = const Value.absent(), + Value attachmentsJson = const Value.absent(), + Value isCancelled = const Value.absent(), + Value isDeleted = const Value.absent(), + Value rawJson = const Value.absent(), + Value createdAtServer = const Value.absent(), + Value updatedAtServer = const Value.absent(), + required int createdAtLocal, + required int updatedAtLocal, + Value syncStatus = const Value.absent(), + Value baselineRawJson = const Value.absent(), + Value rowid = const Value.absent(), + }) => CalendarEventsCompanion.insert( + id: id, + accountId: accountId, + calendarSourceId: calendarSourceId, + provider: provider, + providerCalendarId: providerCalendarId, + providerEventId: providerEventId, + davCollectionId: davCollectionId, + davObjectId: davObjectId, + davComponentId: davComponentId, + icalUid: icalUid, + recurrenceIdKey: recurrenceIdKey, + occurrenceKey: occurrenceKey, + projectionVersion: projectionVersion, + providerRecurringEventId: providerRecurringEventId, + providerOriginalStartKey: providerOriginalStartKey, + etagOrChangeKey: etagOrChangeKey, + status: status, + title: title, + description: description, + location: location, + locationLatitude: locationLatitude, + locationLongitude: locationLongitude, + allDay: allDay, + startDate: startDate, + startDateTime: startDateTime, + startTimeZone: startTimeZone, + endDate: endDate, + endDateTime: endDateTime, + endTimeZone: endTimeZone, + recurrenceJson: recurrenceJson, + remindersJson: remindersJson, + attendeesJson: attendeesJson, + categoriesJson: categoriesJson, + organizerJson: organizerJson, + creatorJson: creatorJson, + colorId: colorId, + colorHex: colorHex, + visibility: visibility, + transparencyOrShowAs: transparencyOrShowAs, + eventType: eventType, + webLink: webLink, + conferenceJson: conferenceJson, + attachmentsJson: attachmentsJson, + isCancelled: isCancelled, + isDeleted: isDeleted, + rawJson: rawJson, + createdAtServer: createdAtServer, + updatedAtServer: updatedAtServer, + createdAtLocal: createdAtLocal, + updatedAtLocal: updatedAtLocal, + syncStatus: syncStatus, + baselineRawJson: baselineRawJson, + rowid: rowid, + ), + withReferenceMapper: (p0) => p0 + .map( + (e) => ( + e.readTable<$CalendarEventsTable, CalendarEvent>(table), + $$CalendarEventsTableReferences(db, table, e), + ), + ) + .toList(), + prefetchHooksCallback: + ({ + accountId = false, + calendarSourceId = false, + davCollectionId = false, + davObjectId = false, + davComponentId = false, + calendarEventAttendeesRefs = false, + calendarEventRemindersRefs = false, + }) { + return PrefetchHooks( + db: db, + explicitlyWatchedTables: [ + if (calendarEventAttendeesRefs) db.calendarEventAttendees, + if (calendarEventRemindersRefs) db.calendarEventReminders, + ], + addJoins: + < + T extends TableManagerState< + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic + > + >(state) { + if (accountId) { + state = + state.withJoin( + currentTable: table, + currentColumn: table.accountId, + referencedTable: + $$CalendarEventsTableReferences + ._accountIdTable(db), + referencedColumn: + $$CalendarEventsTableReferences + ._accountIdTable(db) + .id, + ) + as T; + } + if (calendarSourceId) { + state = + state.withJoin( + currentTable: table, + currentColumn: table.calendarSourceId, + referencedTable: + $$CalendarEventsTableReferences + ._calendarSourceIdTable(db), + referencedColumn: + $$CalendarEventsTableReferences + ._calendarSourceIdTable(db) + .id, + ) + as T; + } + if (davCollectionId) { + state = + state.withJoin( + currentTable: table, + currentColumn: table.davCollectionId, + referencedTable: + $$CalendarEventsTableReferences + ._davCollectionIdTable(db), + referencedColumn: + $$CalendarEventsTableReferences + ._davCollectionIdTable(db) + .id, + ) + as T; + } + if (davObjectId) { + state = + state.withJoin( + currentTable: table, + currentColumn: table.davObjectId, + referencedTable: + $$CalendarEventsTableReferences + ._davObjectIdTable(db), + referencedColumn: + $$CalendarEventsTableReferences + ._davObjectIdTable(db) + .id, + ) + as T; + } + if (davComponentId) { + state = + state.withJoin( + currentTable: table, + currentColumn: table.davComponentId, + referencedTable: + $$CalendarEventsTableReferences + ._davComponentIdTable(db), + referencedColumn: + $$CalendarEventsTableReferences + ._davComponentIdTable(db) + .id, + ) + as T; + } - GeneratedColumn get conferenceJson => $composableBuilder( - column: $table.conferenceJson, - builder: (column) => column, - ); + return state; + }, + getPrefetchedDataCallback: (items) async { + return [ + if (calendarEventAttendeesRefs) + await $_getPrefetchedData< + CalendarEvent, + $CalendarEventsTable, + CalendarEventAttendee + >( + currentTable: table, + referencedTable: $$CalendarEventsTableReferences + ._calendarEventAttendeesRefsTable(db), + managerFromTypedResult: (p0) => + $$CalendarEventsTableReferences( + db, + table, + p0, + ).calendarEventAttendeesRefs, + referencedItemsForCurrentItem: + (item, referencedItems) => referencedItems.where( + (e) => e.calendarEventId == item.id, + ), + typedResults: items, + ), + if (calendarEventRemindersRefs) + await $_getPrefetchedData< + CalendarEvent, + $CalendarEventsTable, + CalendarEventReminder + >( + currentTable: table, + referencedTable: $$CalendarEventsTableReferences + ._calendarEventRemindersRefsTable(db), + managerFromTypedResult: (p0) => + $$CalendarEventsTableReferences( + db, + table, + p0, + ).calendarEventRemindersRefs, + referencedItemsForCurrentItem: + (item, referencedItems) => referencedItems.where( + (e) => e.calendarEventId == item.id, + ), + typedResults: items, + ), + ]; + }, + ); + }, + ), + ); +} - GeneratedColumn get attachmentsJson => $composableBuilder( - column: $table.attachmentsJson, - builder: (column) => column, - ); +typedef $$CalendarEventsTableProcessedTableManager = + ProcessedTableManager< + _$AppDatabase, + $CalendarEventsTable, + CalendarEvent, + $$CalendarEventsTableFilterComposer, + $$CalendarEventsTableOrderingComposer, + $$CalendarEventsTableAnnotationComposer, + $$CalendarEventsTableCreateCompanionBuilder, + $$CalendarEventsTableUpdateCompanionBuilder, + (CalendarEvent, $$CalendarEventsTableReferences), + CalendarEvent, + PrefetchHooks Function({ + bool accountId, + bool calendarSourceId, + bool davCollectionId, + bool davObjectId, + bool davComponentId, + bool calendarEventAttendeesRefs, + bool calendarEventRemindersRefs, + }) + >; +typedef $$IcalImportReceiptsTableCreateCompanionBuilder = + IcalImportReceiptsCompanion Function({ + required String calendarSourceId, + required String icalUid, + Value eventId, + required String importedAtUtc, + Value rowid, + }); +typedef $$IcalImportReceiptsTableUpdateCompanionBuilder = + IcalImportReceiptsCompanion Function({ + Value calendarSourceId, + Value icalUid, + Value eventId, + Value importedAtUtc, + Value rowid, + }); - GeneratedColumn get isCancelled => $composableBuilder( - column: $table.isCancelled, - builder: (column) => column, +final class $$IcalImportReceiptsTableReferences + extends + BaseReferences< + _$AppDatabase, + $IcalImportReceiptsTable, + IcalImportReceipt + > { + $$IcalImportReceiptsTableReferences( + super.$_db, + super.$_table, + super.$_typedResult, ); - GeneratedColumn get isDeleted => - $composableBuilder(column: $table.isDeleted, builder: (column) => column); - - GeneratedColumn get rawJson => - $composableBuilder(column: $table.rawJson, builder: (column) => column); - - GeneratedColumn get createdAtServer => $composableBuilder( - column: $table.createdAtServer, - builder: (column) => column, - ); + static $CalendarSourcesTable _calendarSourceIdTable(_$AppDatabase db) => + db.calendarSources.createAlias( + 'ical_import_receipts__calendar_source_id__calendar_sources__id', + ); - GeneratedColumn get updatedAtServer => $composableBuilder( - column: $table.updatedAtServer, - builder: (column) => column, - ); + $$CalendarSourcesTableProcessedTableManager get calendarSourceId { + final $_column = $_itemColumn('calendar_source_id')!; - GeneratedColumn get createdAtLocal => $composableBuilder( - column: $table.createdAtLocal, - builder: (column) => column, - ); + final manager = $$CalendarSourcesTableTableManager( + $_db, + $_db.calendarSources, + ).filter((f) => f.id.sqlEquals($_column)); + final item = $_typedResult.readTableOrNull(_calendarSourceIdTable($_db)); + if (item == null) return manager; + return ProcessedTableManager( + manager.$state.copyWith(prefetchedData: [item]), + ); + } +} - GeneratedColumn get updatedAtLocal => $composableBuilder( - column: $table.updatedAtLocal, - builder: (column) => column, +class $$IcalImportReceiptsTableFilterComposer + extends Composer<_$AppDatabase, $IcalImportReceiptsTable> { + $$IcalImportReceiptsTableFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnFilters get icalUid => $composableBuilder( + column: $table.icalUid, + builder: (column) => ColumnFilters(column), ); - GeneratedColumn get syncStatus => $composableBuilder( - column: $table.syncStatus, - builder: (column) => column, + ColumnFilters get eventId => $composableBuilder( + column: $table.eventId, + builder: (column) => ColumnFilters(column), ); - GeneratedColumn get baselineRawJson => $composableBuilder( - column: $table.baselineRawJson, - builder: (column) => column, + ColumnFilters get importedAtUtc => $composableBuilder( + column: $table.importedAtUtc, + builder: (column) => ColumnFilters(column), ); - $$AccountsTableAnnotationComposer get accountId { - final $$AccountsTableAnnotationComposer composer = $composerBuilder( + $$CalendarSourcesTableFilterComposer get calendarSourceId { + final $$CalendarSourcesTableFilterComposer composer = $composerBuilder( composer: this, - getCurrentColumn: (t) => t.accountId, - referencedTable: $db.accounts, + getCurrentColumn: (t) => t.calendarSourceId, + referencedTable: $db.calendarSources, getReferencedColumn: (t) => t.id, builder: ( joinBuilder, { $addJoinBuilderToRootComposer, $removeJoinBuilderFromRootComposer, - }) => $$AccountsTableAnnotationComposer( + }) => $$CalendarSourcesTableFilterComposer( $db: $db, - $table: $db.accounts, + $table: $db.calendarSources, $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, joinBuilder: joinBuilder, $removeJoinBuilderFromRootComposer: @@ -39998,9 +41498,34 @@ class $$CalendarEventsTableAnnotationComposer ); return composer; } +} - $$CalendarSourcesTableAnnotationComposer get calendarSourceId { - final $$CalendarSourcesTableAnnotationComposer composer = $composerBuilder( +class $$IcalImportReceiptsTableOrderingComposer + extends Composer<_$AppDatabase, $IcalImportReceiptsTable> { + $$IcalImportReceiptsTableOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnOrderings get icalUid => $composableBuilder( + column: $table.icalUid, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get eventId => $composableBuilder( + column: $table.eventId, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get importedAtUtc => $composableBuilder( + column: $table.importedAtUtc, + builder: (column) => ColumnOrderings(column), + ); + + $$CalendarSourcesTableOrderingComposer get calendarSourceId { + final $$CalendarSourcesTableOrderingComposer composer = $composerBuilder( composer: this, getCurrentColumn: (t) => t.calendarSourceId, referencedTable: $db.calendarSources, @@ -40010,7 +41535,7 @@ class $$CalendarEventsTableAnnotationComposer joinBuilder, { $addJoinBuilderToRootComposer, $removeJoinBuilderFromRootComposer, - }) => $$CalendarSourcesTableAnnotationComposer( + }) => $$CalendarSourcesTableOrderingComposer( $db: $db, $table: $db.calendarSources, $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, @@ -40021,44 +41546,42 @@ class $$CalendarEventsTableAnnotationComposer ); return composer; } +} - $$DavCollectionsTableAnnotationComposer get davCollectionId { - final $$DavCollectionsTableAnnotationComposer composer = $composerBuilder( - composer: this, - getCurrentColumn: (t) => t.davCollectionId, - referencedTable: $db.davCollections, - getReferencedColumn: (t) => t.id, - builder: - ( - joinBuilder, { - $addJoinBuilderToRootComposer, - $removeJoinBuilderFromRootComposer, - }) => $$DavCollectionsTableAnnotationComposer( - $db: $db, - $table: $db.davCollections, - $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, - joinBuilder: joinBuilder, - $removeJoinBuilderFromRootComposer: - $removeJoinBuilderFromRootComposer, - ), - ); - return composer; - } +class $$IcalImportReceiptsTableAnnotationComposer + extends Composer<_$AppDatabase, $IcalImportReceiptsTable> { + $$IcalImportReceiptsTableAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + GeneratedColumn get icalUid => + $composableBuilder(column: $table.icalUid, builder: (column) => column); - $$DavObjectsTableAnnotationComposer get davObjectId { - final $$DavObjectsTableAnnotationComposer composer = $composerBuilder( + GeneratedColumn get eventId => + $composableBuilder(column: $table.eventId, builder: (column) => column); + + GeneratedColumn get importedAtUtc => $composableBuilder( + column: $table.importedAtUtc, + builder: (column) => column, + ); + + $$CalendarSourcesTableAnnotationComposer get calendarSourceId { + final $$CalendarSourcesTableAnnotationComposer composer = $composerBuilder( composer: this, - getCurrentColumn: (t) => t.davObjectId, - referencedTable: $db.davObjects, + getCurrentColumn: (t) => t.calendarSourceId, + referencedTable: $db.calendarSources, getReferencedColumn: (t) => t.id, builder: ( joinBuilder, { $addJoinBuilderToRootComposer, $removeJoinBuilderFromRootComposer, - }) => $$DavObjectsTableAnnotationComposer( + }) => $$CalendarSourcesTableAnnotationComposer( $db: $db, - $table: $db.davObjects, + $table: $db.calendarSources, $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, joinBuilder: joinBuilder, $removeJoinBuilderFromRootComposer: @@ -40067,623 +41590,592 @@ class $$CalendarEventsTableAnnotationComposer ); return composer; } - - $$DavObjectComponentsTableAnnotationComposer get davComponentId { - final $$DavObjectComponentsTableAnnotationComposer composer = - $composerBuilder( - composer: this, - getCurrentColumn: (t) => t.davComponentId, - referencedTable: $db.davObjectComponents, - getReferencedColumn: (t) => t.id, - builder: - ( - joinBuilder, { - $addJoinBuilderToRootComposer, - $removeJoinBuilderFromRootComposer, - }) => $$DavObjectComponentsTableAnnotationComposer( - $db: $db, - $table: $db.davObjectComponents, - $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, - joinBuilder: joinBuilder, - $removeJoinBuilderFromRootComposer: - $removeJoinBuilderFromRootComposer, - ), - ); - return composer; - } - - Expression calendarEventAttendeesRefs( - Expression Function($$CalendarEventAttendeesTableAnnotationComposer a) f, - ) { - final $$CalendarEventAttendeesTableAnnotationComposer composer = - $composerBuilder( - composer: this, - getCurrentColumn: (t) => t.id, - referencedTable: $db.calendarEventAttendees, - getReferencedColumn: (t) => t.calendarEventId, - builder: - ( - joinBuilder, { - $addJoinBuilderToRootComposer, - $removeJoinBuilderFromRootComposer, - }) => $$CalendarEventAttendeesTableAnnotationComposer( - $db: $db, - $table: $db.calendarEventAttendees, - $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, - joinBuilder: joinBuilder, - $removeJoinBuilderFromRootComposer: - $removeJoinBuilderFromRootComposer, - ), - ); - return f(composer); - } - - Expression calendarEventRemindersRefs( - Expression Function($$CalendarEventRemindersTableAnnotationComposer a) f, - ) { - final $$CalendarEventRemindersTableAnnotationComposer composer = - $composerBuilder( - composer: this, - getCurrentColumn: (t) => t.id, - referencedTable: $db.calendarEventReminders, - getReferencedColumn: (t) => t.calendarEventId, - builder: - ( - joinBuilder, { - $addJoinBuilderToRootComposer, - $removeJoinBuilderFromRootComposer, - }) => $$CalendarEventRemindersTableAnnotationComposer( - $db: $db, - $table: $db.calendarEventReminders, - $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, - joinBuilder: joinBuilder, - $removeJoinBuilderFromRootComposer: - $removeJoinBuilderFromRootComposer, - ), - ); - return f(composer); - } } -class $$CalendarEventsTableTableManager +class $$IcalImportReceiptsTableTableManager extends RootTableManager< _$AppDatabase, - $CalendarEventsTable, - CalendarEvent, - $$CalendarEventsTableFilterComposer, - $$CalendarEventsTableOrderingComposer, - $$CalendarEventsTableAnnotationComposer, - $$CalendarEventsTableCreateCompanionBuilder, - $$CalendarEventsTableUpdateCompanionBuilder, - (CalendarEvent, $$CalendarEventsTableReferences), - CalendarEvent, - PrefetchHooks Function({ - bool accountId, - bool calendarSourceId, - bool davCollectionId, - bool davObjectId, - bool davComponentId, - bool calendarEventAttendeesRefs, - bool calendarEventRemindersRefs, - }) + $IcalImportReceiptsTable, + IcalImportReceipt, + $$IcalImportReceiptsTableFilterComposer, + $$IcalImportReceiptsTableOrderingComposer, + $$IcalImportReceiptsTableAnnotationComposer, + $$IcalImportReceiptsTableCreateCompanionBuilder, + $$IcalImportReceiptsTableUpdateCompanionBuilder, + (IcalImportReceipt, $$IcalImportReceiptsTableReferences), + IcalImportReceipt, + PrefetchHooks Function({bool calendarSourceId}) > { - $$CalendarEventsTableTableManager( + $$IcalImportReceiptsTableTableManager( _$AppDatabase db, - $CalendarEventsTable table, + $IcalImportReceiptsTable table, ) : super( TableManagerState( db: db, table: table, createFilteringComposer: () => - $$CalendarEventsTableFilterComposer($db: db, $table: table), + $$IcalImportReceiptsTableFilterComposer($db: db, $table: table), createOrderingComposer: () => - $$CalendarEventsTableOrderingComposer($db: db, $table: table), + $$IcalImportReceiptsTableOrderingComposer($db: db, $table: table), createComputedFieldComposer: () => - $$CalendarEventsTableAnnotationComposer($db: db, $table: table), + $$IcalImportReceiptsTableAnnotationComposer( + $db: db, + $table: table, + ), updateCompanionCallback: ({ - Value id = const Value.absent(), - Value accountId = const Value.absent(), Value calendarSourceId = const Value.absent(), - Value provider = const Value.absent(), - Value providerCalendarId = const Value.absent(), - Value providerEventId = const Value.absent(), - Value davCollectionId = const Value.absent(), - Value davObjectId = const Value.absent(), - Value davComponentId = const Value.absent(), - Value icalUid = const Value.absent(), - Value recurrenceIdKey = const Value.absent(), - Value occurrenceKey = const Value.absent(), - Value projectionVersion = const Value.absent(), - Value providerRecurringEventId = const Value.absent(), - Value providerOriginalStartKey = const Value.absent(), - Value etagOrChangeKey = const Value.absent(), - Value status = const Value.absent(), - Value title = const Value.absent(), - Value description = const Value.absent(), - Value location = const Value.absent(), - Value locationLatitude = const Value.absent(), - Value locationLongitude = const Value.absent(), - Value allDay = const Value.absent(), - Value startDate = const Value.absent(), - Value startDateTime = const Value.absent(), - Value startTimeZone = const Value.absent(), - Value endDate = const Value.absent(), - Value endDateTime = const Value.absent(), - Value endTimeZone = const Value.absent(), - Value recurrenceJson = const Value.absent(), - Value remindersJson = const Value.absent(), - Value attendeesJson = const Value.absent(), - Value categoriesJson = const Value.absent(), - Value organizerJson = const Value.absent(), - Value creatorJson = const Value.absent(), - Value colorId = const Value.absent(), - Value colorHex = const Value.absent(), - Value visibility = const Value.absent(), - Value transparencyOrShowAs = const Value.absent(), - Value eventType = const Value.absent(), - Value webLink = const Value.absent(), - Value conferenceJson = const Value.absent(), - Value attachmentsJson = const Value.absent(), - Value isCancelled = const Value.absent(), - Value isDeleted = const Value.absent(), - Value rawJson = const Value.absent(), - Value createdAtServer = const Value.absent(), - Value updatedAtServer = const Value.absent(), - Value createdAtLocal = const Value.absent(), - Value updatedAtLocal = const Value.absent(), - Value syncStatus = const Value.absent(), - Value baselineRawJson = const Value.absent(), + Value icalUid = const Value.absent(), + Value eventId = const Value.absent(), + Value importedAtUtc = const Value.absent(), Value rowid = const Value.absent(), - }) => CalendarEventsCompanion( - id: id, - accountId: accountId, + }) => IcalImportReceiptsCompanion( calendarSourceId: calendarSourceId, - provider: provider, - providerCalendarId: providerCalendarId, - providerEventId: providerEventId, - davCollectionId: davCollectionId, - davObjectId: davObjectId, - davComponentId: davComponentId, icalUid: icalUid, - recurrenceIdKey: recurrenceIdKey, - occurrenceKey: occurrenceKey, - projectionVersion: projectionVersion, - providerRecurringEventId: providerRecurringEventId, - providerOriginalStartKey: providerOriginalStartKey, - etagOrChangeKey: etagOrChangeKey, - status: status, - title: title, - description: description, - location: location, - locationLatitude: locationLatitude, - locationLongitude: locationLongitude, - allDay: allDay, - startDate: startDate, - startDateTime: startDateTime, - startTimeZone: startTimeZone, - endDate: endDate, - endDateTime: endDateTime, - endTimeZone: endTimeZone, - recurrenceJson: recurrenceJson, - remindersJson: remindersJson, - attendeesJson: attendeesJson, - categoriesJson: categoriesJson, - organizerJson: organizerJson, - creatorJson: creatorJson, - colorId: colorId, - colorHex: colorHex, - visibility: visibility, - transparencyOrShowAs: transparencyOrShowAs, - eventType: eventType, - webLink: webLink, - conferenceJson: conferenceJson, - attachmentsJson: attachmentsJson, - isCancelled: isCancelled, - isDeleted: isDeleted, - rawJson: rawJson, - createdAtServer: createdAtServer, - updatedAtServer: updatedAtServer, - createdAtLocal: createdAtLocal, - updatedAtLocal: updatedAtLocal, - syncStatus: syncStatus, - baselineRawJson: baselineRawJson, + eventId: eventId, + importedAtUtc: importedAtUtc, rowid: rowid, ), createCompanionCallback: ({ - required String id, - required String accountId, required String calendarSourceId, - required String provider, - required String providerCalendarId, - required String providerEventId, - Value davCollectionId = const Value.absent(), - Value davObjectId = const Value.absent(), - Value davComponentId = const Value.absent(), - Value icalUid = const Value.absent(), - Value recurrenceIdKey = const Value.absent(), - Value occurrenceKey = const Value.absent(), - Value projectionVersion = const Value.absent(), - Value providerRecurringEventId = const Value.absent(), - Value providerOriginalStartKey = const Value.absent(), - Value etagOrChangeKey = const Value.absent(), - Value status = const Value.absent(), - required String title, - Value description = const Value.absent(), - Value location = const Value.absent(), - Value locationLatitude = const Value.absent(), - Value locationLongitude = const Value.absent(), - Value allDay = const Value.absent(), - Value startDate = const Value.absent(), - Value startDateTime = const Value.absent(), - Value startTimeZone = const Value.absent(), - Value endDate = const Value.absent(), - Value endDateTime = const Value.absent(), - Value endTimeZone = const Value.absent(), - Value recurrenceJson = const Value.absent(), - Value remindersJson = const Value.absent(), - Value attendeesJson = const Value.absent(), - Value categoriesJson = const Value.absent(), - Value organizerJson = const Value.absent(), - Value creatorJson = const Value.absent(), - Value colorId = const Value.absent(), - Value colorHex = const Value.absent(), - Value visibility = const Value.absent(), - Value transparencyOrShowAs = const Value.absent(), - Value eventType = const Value.absent(), - Value webLink = const Value.absent(), - Value conferenceJson = const Value.absent(), - Value attachmentsJson = const Value.absent(), - Value isCancelled = const Value.absent(), - Value isDeleted = const Value.absent(), - Value rawJson = const Value.absent(), - Value createdAtServer = const Value.absent(), - Value updatedAtServer = const Value.absent(), - required int createdAtLocal, - required int updatedAtLocal, - Value syncStatus = const Value.absent(), - Value baselineRawJson = const Value.absent(), + required String icalUid, + Value eventId = const Value.absent(), + required String importedAtUtc, Value rowid = const Value.absent(), - }) => CalendarEventsCompanion.insert( - id: id, - accountId: accountId, + }) => IcalImportReceiptsCompanion.insert( calendarSourceId: calendarSourceId, - provider: provider, - providerCalendarId: providerCalendarId, - providerEventId: providerEventId, - davCollectionId: davCollectionId, - davObjectId: davObjectId, - davComponentId: davComponentId, icalUid: icalUid, - recurrenceIdKey: recurrenceIdKey, - occurrenceKey: occurrenceKey, - projectionVersion: projectionVersion, - providerRecurringEventId: providerRecurringEventId, - providerOriginalStartKey: providerOriginalStartKey, - etagOrChangeKey: etagOrChangeKey, - status: status, - title: title, - description: description, - location: location, - locationLatitude: locationLatitude, - locationLongitude: locationLongitude, - allDay: allDay, - startDate: startDate, - startDateTime: startDateTime, - startTimeZone: startTimeZone, - endDate: endDate, - endDateTime: endDateTime, - endTimeZone: endTimeZone, - recurrenceJson: recurrenceJson, - remindersJson: remindersJson, - attendeesJson: attendeesJson, - categoriesJson: categoriesJson, - organizerJson: organizerJson, - creatorJson: creatorJson, - colorId: colorId, - colorHex: colorHex, - visibility: visibility, - transparencyOrShowAs: transparencyOrShowAs, - eventType: eventType, - webLink: webLink, - conferenceJson: conferenceJson, - attachmentsJson: attachmentsJson, - isCancelled: isCancelled, - isDeleted: isDeleted, - rawJson: rawJson, - createdAtServer: createdAtServer, - updatedAtServer: updatedAtServer, - createdAtLocal: createdAtLocal, - updatedAtLocal: updatedAtLocal, - syncStatus: syncStatus, - baselineRawJson: baselineRawJson, + eventId: eventId, + importedAtUtc: importedAtUtc, rowid: rowid, ), withReferenceMapper: (p0) => p0 .map( (e) => ( - e.readTable(table), - $$CalendarEventsTableReferences(db, table, e), + e.readTable<$IcalImportReceiptsTable, IcalImportReceipt>( + table, + ), + $$IcalImportReceiptsTableReferences(db, table, e), ), ) .toList(), - prefetchHooksCallback: - ({ - accountId = false, - calendarSourceId = false, - davCollectionId = false, - davObjectId = false, - davComponentId = false, - calendarEventAttendeesRefs = false, - calendarEventRemindersRefs = false, - }) { - return PrefetchHooks( - db: db, - explicitlyWatchedTables: [ - if (calendarEventAttendeesRefs) db.calendarEventAttendees, - if (calendarEventRemindersRefs) db.calendarEventReminders, - ], - addJoins: - < - T extends TableManagerState< - dynamic, - dynamic, - dynamic, - dynamic, - dynamic, - dynamic, - dynamic, - dynamic, - dynamic, - dynamic, - dynamic - > - >(state) { - if (accountId) { - state = - state.withJoin( - currentTable: table, - currentColumn: table.accountId, - referencedTable: - $$CalendarEventsTableReferences - ._accountIdTable(db), - referencedColumn: - $$CalendarEventsTableReferences - ._accountIdTable(db) - .id, - ) - as T; - } - if (calendarSourceId) { - state = - state.withJoin( - currentTable: table, - currentColumn: table.calendarSourceId, - referencedTable: - $$CalendarEventsTableReferences - ._calendarSourceIdTable(db), - referencedColumn: - $$CalendarEventsTableReferences - ._calendarSourceIdTable(db) - .id, - ) - as T; - } - if (davCollectionId) { - state = - state.withJoin( - currentTable: table, - currentColumn: table.davCollectionId, - referencedTable: - $$CalendarEventsTableReferences - ._davCollectionIdTable(db), - referencedColumn: - $$CalendarEventsTableReferences - ._davCollectionIdTable(db) - .id, - ) - as T; - } - if (davObjectId) { - state = - state.withJoin( - currentTable: table, - currentColumn: table.davObjectId, - referencedTable: - $$CalendarEventsTableReferences - ._davObjectIdTable(db), - referencedColumn: - $$CalendarEventsTableReferences - ._davObjectIdTable(db) - .id, - ) - as T; - } - if (davComponentId) { - state = - state.withJoin( - currentTable: table, - currentColumn: table.davComponentId, - referencedTable: - $$CalendarEventsTableReferences - ._davComponentIdTable(db), - referencedColumn: - $$CalendarEventsTableReferences - ._davComponentIdTable(db) - .id, - ) - as T; - } + prefetchHooksCallback: ({calendarSourceId = false}) { + return PrefetchHooks( + db: db, + explicitlyWatchedTables: [], + addJoins: + < + T extends TableManagerState< + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic + > + >(state) { + if (calendarSourceId) { + state = + state.withJoin( + currentTable: table, + currentColumn: table.calendarSourceId, + referencedTable: + $$IcalImportReceiptsTableReferences + ._calendarSourceIdTable(db), + referencedColumn: + $$IcalImportReceiptsTableReferences + ._calendarSourceIdTable(db) + .id, + ) + as T; + } + + return state; + }, + getPrefetchedDataCallback: (items) async { + return []; + }, + ); + }, + ), + ); +} + +typedef $$IcalImportReceiptsTableProcessedTableManager = + ProcessedTableManager< + _$AppDatabase, + $IcalImportReceiptsTable, + IcalImportReceipt, + $$IcalImportReceiptsTableFilterComposer, + $$IcalImportReceiptsTableOrderingComposer, + $$IcalImportReceiptsTableAnnotationComposer, + $$IcalImportReceiptsTableCreateCompanionBuilder, + $$IcalImportReceiptsTableUpdateCompanionBuilder, + (IcalImportReceipt, $$IcalImportReceiptsTableReferences), + IcalImportReceipt, + PrefetchHooks Function({bool calendarSourceId}) + >; +typedef $$WebCalSubscriptionsTableCreateCompanionBuilder = + WebCalSubscriptionsCompanion Function({ + required String id, + required String accountId, + required String calendarSourceId, + required String feedFingerprint, + required String safeOrigin, + Value validatorTargetFingerprint, + Value etag, + Value lastModified, + Value contentType, + required String snapshotIcsBody, + required String rawBodyHash, + required String semanticHash, + required String refreshMode, + Value serverRefreshIntervalSeconds, + required String nextRefreshAtUtc, + Value lastCheckedAtUtc, + Value lastSuccessfulSyncAtUtc, + Value lastChangedAtUtc, + Value consecutiveFailureCount, + Value lastFailureCode, + Value lastFailureHttpStatus, + Value generation, + Value parserVersion, + Value projectionVersion, + Value projectionRangeStartUtc, + Value projectionRangeEndUtc, + required String createdAtUtc, + required String updatedAtUtc, + Value rowid, + }); +typedef $$WebCalSubscriptionsTableUpdateCompanionBuilder = + WebCalSubscriptionsCompanion Function({ + Value id, + Value accountId, + Value calendarSourceId, + Value feedFingerprint, + Value safeOrigin, + Value validatorTargetFingerprint, + Value etag, + Value lastModified, + Value contentType, + Value snapshotIcsBody, + Value rawBodyHash, + Value semanticHash, + Value refreshMode, + Value serverRefreshIntervalSeconds, + Value nextRefreshAtUtc, + Value lastCheckedAtUtc, + Value lastSuccessfulSyncAtUtc, + Value lastChangedAtUtc, + Value consecutiveFailureCount, + Value lastFailureCode, + Value lastFailureHttpStatus, + Value generation, + Value parserVersion, + Value projectionVersion, + Value projectionRangeStartUtc, + Value projectionRangeEndUtc, + Value createdAtUtc, + Value updatedAtUtc, + Value rowid, + }); + +final class $$WebCalSubscriptionsTableReferences + extends + BaseReferences< + _$AppDatabase, + $WebCalSubscriptionsTable, + WebCalSubscription + > { + $$WebCalSubscriptionsTableReferences( + super.$_db, + super.$_table, + super.$_typedResult, + ); + + static $AccountsTable _accountIdTable(_$AppDatabase db) => db.accounts + .createAlias('web_cal_subscriptions__account_id__accounts__id'); + + $$AccountsTableProcessedTableManager get accountId { + final $_column = $_itemColumn('account_id')!; + + final manager = $$AccountsTableTableManager( + $_db, + $_db.accounts, + ).filter((f) => f.id.sqlEquals($_column)); + final item = $_typedResult.readTableOrNull(_accountIdTable($_db)); + if (item == null) return manager; + return ProcessedTableManager( + manager.$state.copyWith(prefetchedData: [item]), + ); + } + + static $CalendarSourcesTable _calendarSourceIdTable(_$AppDatabase db) => + db.calendarSources.createAlias( + 'web_cal_subscriptions__calendar_source_id__calendar_sources__id', + ); + + $$CalendarSourcesTableProcessedTableManager get calendarSourceId { + final $_column = $_itemColumn('calendar_source_id')!; + + final manager = $$CalendarSourcesTableTableManager( + $_db, + $_db.calendarSources, + ).filter((f) => f.id.sqlEquals($_column)); + final item = $_typedResult.readTableOrNull(_calendarSourceIdTable($_db)); + if (item == null) return manager; + return ProcessedTableManager( + manager.$state.copyWith(prefetchedData: [item]), + ); + } +} + +class $$WebCalSubscriptionsTableFilterComposer + extends Composer<_$AppDatabase, $WebCalSubscriptionsTable> { + $$WebCalSubscriptionsTableFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnFilters get id => $composableBuilder( + column: $table.id, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get feedFingerprint => $composableBuilder( + column: $table.feedFingerprint, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get safeOrigin => $composableBuilder( + column: $table.safeOrigin, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get validatorTargetFingerprint => $composableBuilder( + column: $table.validatorTargetFingerprint, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get etag => $composableBuilder( + column: $table.etag, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get lastModified => $composableBuilder( + column: $table.lastModified, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get contentType => $composableBuilder( + column: $table.contentType, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get snapshotIcsBody => $composableBuilder( + column: $table.snapshotIcsBody, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get rawBodyHash => $composableBuilder( + column: $table.rawBodyHash, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get semanticHash => $composableBuilder( + column: $table.semanticHash, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get refreshMode => $composableBuilder( + column: $table.refreshMode, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get serverRefreshIntervalSeconds => $composableBuilder( + column: $table.serverRefreshIntervalSeconds, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get nextRefreshAtUtc => $composableBuilder( + column: $table.nextRefreshAtUtc, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get lastCheckedAtUtc => $composableBuilder( + column: $table.lastCheckedAtUtc, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get lastSuccessfulSyncAtUtc => $composableBuilder( + column: $table.lastSuccessfulSyncAtUtc, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get lastChangedAtUtc => $composableBuilder( + column: $table.lastChangedAtUtc, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get consecutiveFailureCount => $composableBuilder( + column: $table.consecutiveFailureCount, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get lastFailureCode => $composableBuilder( + column: $table.lastFailureCode, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get lastFailureHttpStatus => $composableBuilder( + column: $table.lastFailureHttpStatus, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get generation => $composableBuilder( + column: $table.generation, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get parserVersion => $composableBuilder( + column: $table.parserVersion, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get projectionVersion => $composableBuilder( + column: $table.projectionVersion, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get projectionRangeStartUtc => $composableBuilder( + column: $table.projectionRangeStartUtc, + builder: (column) => ColumnFilters(column), + ); - return state; - }, - getPrefetchedDataCallback: (items) async { - return [ - if (calendarEventAttendeesRefs) - await $_getPrefetchedData< - CalendarEvent, - $CalendarEventsTable, - CalendarEventAttendee - >( - currentTable: table, - referencedTable: $$CalendarEventsTableReferences - ._calendarEventAttendeesRefsTable(db), - managerFromTypedResult: (p0) => - $$CalendarEventsTableReferences( - db, - table, - p0, - ).calendarEventAttendeesRefs, - referencedItemsForCurrentItem: - (item, referencedItems) => referencedItems.where( - (e) => e.calendarEventId == item.id, - ), - typedResults: items, - ), - if (calendarEventRemindersRefs) - await $_getPrefetchedData< - CalendarEvent, - $CalendarEventsTable, - CalendarEventReminder - >( - currentTable: table, - referencedTable: $$CalendarEventsTableReferences - ._calendarEventRemindersRefsTable(db), - managerFromTypedResult: (p0) => - $$CalendarEventsTableReferences( - db, - table, - p0, - ).calendarEventRemindersRefs, - referencedItemsForCurrentItem: - (item, referencedItems) => referencedItems.where( - (e) => e.calendarEventId == item.id, - ), - typedResults: items, - ), - ]; - }, - ); - }, - ), - ); + ColumnFilters get projectionRangeEndUtc => $composableBuilder( + column: $table.projectionRangeEndUtc, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get createdAtUtc => $composableBuilder( + column: $table.createdAtUtc, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get updatedAtUtc => $composableBuilder( + column: $table.updatedAtUtc, + builder: (column) => ColumnFilters(column), + ); + + $$AccountsTableFilterComposer get accountId { + final $$AccountsTableFilterComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.accountId, + referencedTable: $db.accounts, + getReferencedColumn: (t) => t.id, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$AccountsTableFilterComposer( + $db: $db, + $table: $db.accounts, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return composer; + } + + $$CalendarSourcesTableFilterComposer get calendarSourceId { + final $$CalendarSourcesTableFilterComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.calendarSourceId, + referencedTable: $db.calendarSources, + getReferencedColumn: (t) => t.id, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$CalendarSourcesTableFilterComposer( + $db: $db, + $table: $db.calendarSources, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return composer; + } } -typedef $$CalendarEventsTableProcessedTableManager = - ProcessedTableManager< - _$AppDatabase, - $CalendarEventsTable, - CalendarEvent, - $$CalendarEventsTableFilterComposer, - $$CalendarEventsTableOrderingComposer, - $$CalendarEventsTableAnnotationComposer, - $$CalendarEventsTableCreateCompanionBuilder, - $$CalendarEventsTableUpdateCompanionBuilder, - (CalendarEvent, $$CalendarEventsTableReferences), - CalendarEvent, - PrefetchHooks Function({ - bool accountId, - bool calendarSourceId, - bool davCollectionId, - bool davObjectId, - bool davComponentId, - bool calendarEventAttendeesRefs, - bool calendarEventRemindersRefs, - }) - >; -typedef $$IcalImportReceiptsTableCreateCompanionBuilder = - IcalImportReceiptsCompanion Function({ - required String calendarSourceId, - required String icalUid, - Value eventId, - required String importedAtUtc, - Value rowid, - }); -typedef $$IcalImportReceiptsTableUpdateCompanionBuilder = - IcalImportReceiptsCompanion Function({ - Value calendarSourceId, - Value icalUid, - Value eventId, - Value importedAtUtc, - Value rowid, - }); +class $$WebCalSubscriptionsTableOrderingComposer + extends Composer<_$AppDatabase, $WebCalSubscriptionsTable> { + $$WebCalSubscriptionsTableOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnOrderings get id => $composableBuilder( + column: $table.id, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get feedFingerprint => $composableBuilder( + column: $table.feedFingerprint, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get safeOrigin => $composableBuilder( + column: $table.safeOrigin, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get validatorTargetFingerprint => $composableBuilder( + column: $table.validatorTargetFingerprint, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get etag => $composableBuilder( + column: $table.etag, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get lastModified => $composableBuilder( + column: $table.lastModified, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get contentType => $composableBuilder( + column: $table.contentType, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get snapshotIcsBody => $composableBuilder( + column: $table.snapshotIcsBody, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get rawBodyHash => $composableBuilder( + column: $table.rawBodyHash, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get semanticHash => $composableBuilder( + column: $table.semanticHash, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get refreshMode => $composableBuilder( + column: $table.refreshMode, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get serverRefreshIntervalSeconds => $composableBuilder( + column: $table.serverRefreshIntervalSeconds, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get nextRefreshAtUtc => $composableBuilder( + column: $table.nextRefreshAtUtc, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get lastCheckedAtUtc => $composableBuilder( + column: $table.lastCheckedAtUtc, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get lastSuccessfulSyncAtUtc => $composableBuilder( + column: $table.lastSuccessfulSyncAtUtc, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get lastChangedAtUtc => $composableBuilder( + column: $table.lastChangedAtUtc, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get consecutiveFailureCount => $composableBuilder( + column: $table.consecutiveFailureCount, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get lastFailureCode => $composableBuilder( + column: $table.lastFailureCode, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get lastFailureHttpStatus => $composableBuilder( + column: $table.lastFailureHttpStatus, + builder: (column) => ColumnOrderings(column), + ); -final class $$IcalImportReceiptsTableReferences - extends - BaseReferences< - _$AppDatabase, - $IcalImportReceiptsTable, - IcalImportReceipt - > { - $$IcalImportReceiptsTableReferences( - super.$_db, - super.$_table, - super.$_typedResult, + ColumnOrderings get generation => $composableBuilder( + column: $table.generation, + builder: (column) => ColumnOrderings(column), ); - static $CalendarSourcesTable _calendarSourceIdTable(_$AppDatabase db) => - db.calendarSources.createAlias( - $_aliasNameGenerator( - db.icalImportReceipts.calendarSourceId, - db.calendarSources.id, - ), - ); + ColumnOrderings get parserVersion => $composableBuilder( + column: $table.parserVersion, + builder: (column) => ColumnOrderings(column), + ); - $$CalendarSourcesTableProcessedTableManager get calendarSourceId { - final $_column = $_itemColumn('calendar_source_id')!; + ColumnOrderings get projectionVersion => $composableBuilder( + column: $table.projectionVersion, + builder: (column) => ColumnOrderings(column), + ); - final manager = $$CalendarSourcesTableTableManager( - $_db, - $_db.calendarSources, - ).filter((f) => f.id.sqlEquals($_column)); - final item = $_typedResult.readTableOrNull(_calendarSourceIdTable($_db)); - if (item == null) return manager; - return ProcessedTableManager( - manager.$state.copyWith(prefetchedData: [item]), - ); - } -} + ColumnOrderings get projectionRangeStartUtc => $composableBuilder( + column: $table.projectionRangeStartUtc, + builder: (column) => ColumnOrderings(column), + ); -class $$IcalImportReceiptsTableFilterComposer - extends Composer<_$AppDatabase, $IcalImportReceiptsTable> { - $$IcalImportReceiptsTableFilterComposer({ - required super.$db, - required super.$table, - super.joinBuilder, - super.$addJoinBuilderToRootComposer, - super.$removeJoinBuilderFromRootComposer, - }); - ColumnFilters get icalUid => $composableBuilder( - column: $table.icalUid, - builder: (column) => ColumnFilters(column), + ColumnOrderings get projectionRangeEndUtc => $composableBuilder( + column: $table.projectionRangeEndUtc, + builder: (column) => ColumnOrderings(column), ); - ColumnFilters get eventId => $composableBuilder( - column: $table.eventId, - builder: (column) => ColumnFilters(column), + ColumnOrderings get createdAtUtc => $composableBuilder( + column: $table.createdAtUtc, + builder: (column) => ColumnOrderings(column), ); - ColumnFilters get importedAtUtc => $composableBuilder( - column: $table.importedAtUtc, - builder: (column) => ColumnFilters(column), + ColumnOrderings get updatedAtUtc => $composableBuilder( + column: $table.updatedAtUtc, + builder: (column) => ColumnOrderings(column), ); - $$CalendarSourcesTableFilterComposer get calendarSourceId { - final $$CalendarSourcesTableFilterComposer composer = $composerBuilder( + $$AccountsTableOrderingComposer get accountId { + final $$AccountsTableOrderingComposer composer = $composerBuilder( composer: this, - getCurrentColumn: (t) => t.calendarSourceId, - referencedTable: $db.calendarSources, + getCurrentColumn: (t) => t.accountId, + referencedTable: $db.accounts, getReferencedColumn: (t) => t.id, builder: ( joinBuilder, { $addJoinBuilderToRootComposer, $removeJoinBuilderFromRootComposer, - }) => $$CalendarSourcesTableFilterComposer( + }) => $$AccountsTableOrderingComposer( $db: $db, - $table: $db.calendarSources, + $table: $db.accounts, $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, joinBuilder: joinBuilder, $removeJoinBuilderFromRootComposer: @@ -40692,31 +42184,6 @@ class $$IcalImportReceiptsTableFilterComposer ); return composer; } -} - -class $$IcalImportReceiptsTableOrderingComposer - extends Composer<_$AppDatabase, $IcalImportReceiptsTable> { - $$IcalImportReceiptsTableOrderingComposer({ - required super.$db, - required super.$table, - super.joinBuilder, - super.$addJoinBuilderToRootComposer, - super.$removeJoinBuilderFromRootComposer, - }); - ColumnOrderings get icalUid => $composableBuilder( - column: $table.icalUid, - builder: (column) => ColumnOrderings(column), - ); - - ColumnOrderings get eventId => $composableBuilder( - column: $table.eventId, - builder: (column) => ColumnOrderings(column), - ); - - ColumnOrderings get importedAtUtc => $composableBuilder( - column: $table.importedAtUtc, - builder: (column) => ColumnOrderings(column), - ); $$CalendarSourcesTableOrderingComposer get calendarSourceId { final $$CalendarSourcesTableOrderingComposer composer = $composerBuilder( @@ -40742,26 +42209,164 @@ class $$IcalImportReceiptsTableOrderingComposer } } -class $$IcalImportReceiptsTableAnnotationComposer - extends Composer<_$AppDatabase, $IcalImportReceiptsTable> { - $$IcalImportReceiptsTableAnnotationComposer({ +class $$WebCalSubscriptionsTableAnnotationComposer + extends Composer<_$AppDatabase, $WebCalSubscriptionsTable> { + $$WebCalSubscriptionsTableAnnotationComposer({ required super.$db, required super.$table, super.joinBuilder, super.$addJoinBuilderToRootComposer, super.$removeJoinBuilderFromRootComposer, }); - GeneratedColumn get icalUid => - $composableBuilder(column: $table.icalUid, builder: (column) => column); + GeneratedColumn get id => + $composableBuilder(column: $table.id, builder: (column) => column); - GeneratedColumn get eventId => - $composableBuilder(column: $table.eventId, builder: (column) => column); + GeneratedColumn get feedFingerprint => $composableBuilder( + column: $table.feedFingerprint, + builder: (column) => column, + ); - GeneratedColumn get importedAtUtc => $composableBuilder( - column: $table.importedAtUtc, + GeneratedColumn get safeOrigin => $composableBuilder( + column: $table.safeOrigin, builder: (column) => column, ); + GeneratedColumn get validatorTargetFingerprint => $composableBuilder( + column: $table.validatorTargetFingerprint, + builder: (column) => column, + ); + + GeneratedColumn get etag => + $composableBuilder(column: $table.etag, builder: (column) => column); + + GeneratedColumn get lastModified => $composableBuilder( + column: $table.lastModified, + builder: (column) => column, + ); + + GeneratedColumn get contentType => $composableBuilder( + column: $table.contentType, + builder: (column) => column, + ); + + GeneratedColumn get snapshotIcsBody => $composableBuilder( + column: $table.snapshotIcsBody, + builder: (column) => column, + ); + + GeneratedColumn get rawBodyHash => $composableBuilder( + column: $table.rawBodyHash, + builder: (column) => column, + ); + + GeneratedColumn get semanticHash => $composableBuilder( + column: $table.semanticHash, + builder: (column) => column, + ); + + GeneratedColumn get refreshMode => $composableBuilder( + column: $table.refreshMode, + builder: (column) => column, + ); + + GeneratedColumn get serverRefreshIntervalSeconds => $composableBuilder( + column: $table.serverRefreshIntervalSeconds, + builder: (column) => column, + ); + + GeneratedColumn get nextRefreshAtUtc => $composableBuilder( + column: $table.nextRefreshAtUtc, + builder: (column) => column, + ); + + GeneratedColumn get lastCheckedAtUtc => $composableBuilder( + column: $table.lastCheckedAtUtc, + builder: (column) => column, + ); + + GeneratedColumn get lastSuccessfulSyncAtUtc => $composableBuilder( + column: $table.lastSuccessfulSyncAtUtc, + builder: (column) => column, + ); + + GeneratedColumn get lastChangedAtUtc => $composableBuilder( + column: $table.lastChangedAtUtc, + builder: (column) => column, + ); + + GeneratedColumn get consecutiveFailureCount => $composableBuilder( + column: $table.consecutiveFailureCount, + builder: (column) => column, + ); + + GeneratedColumn get lastFailureCode => $composableBuilder( + column: $table.lastFailureCode, + builder: (column) => column, + ); + + GeneratedColumn get lastFailureHttpStatus => $composableBuilder( + column: $table.lastFailureHttpStatus, + builder: (column) => column, + ); + + GeneratedColumn get generation => $composableBuilder( + column: $table.generation, + builder: (column) => column, + ); + + GeneratedColumn get parserVersion => $composableBuilder( + column: $table.parserVersion, + builder: (column) => column, + ); + + GeneratedColumn get projectionVersion => $composableBuilder( + column: $table.projectionVersion, + builder: (column) => column, + ); + + GeneratedColumn get projectionRangeStartUtc => $composableBuilder( + column: $table.projectionRangeStartUtc, + builder: (column) => column, + ); + + GeneratedColumn get projectionRangeEndUtc => $composableBuilder( + column: $table.projectionRangeEndUtc, + builder: (column) => column, + ); + + GeneratedColumn get createdAtUtc => $composableBuilder( + column: $table.createdAtUtc, + builder: (column) => column, + ); + + GeneratedColumn get updatedAtUtc => $composableBuilder( + column: $table.updatedAtUtc, + builder: (column) => column, + ); + + $$AccountsTableAnnotationComposer get accountId { + final $$AccountsTableAnnotationComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.accountId, + referencedTable: $db.accounts, + getReferencedColumn: (t) => t.id, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$AccountsTableAnnotationComposer( + $db: $db, + $table: $db.accounts, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return composer; + } + $$CalendarSourcesTableAnnotationComposer get calendarSourceId { final $$CalendarSourcesTableAnnotationComposer composer = $composerBuilder( composer: this, @@ -40786,247 +42391,306 @@ class $$IcalImportReceiptsTableAnnotationComposer } } -class $$IcalImportReceiptsTableTableManager +class $$WebCalSubscriptionsTableTableManager extends RootTableManager< _$AppDatabase, - $IcalImportReceiptsTable, - IcalImportReceipt, - $$IcalImportReceiptsTableFilterComposer, - $$IcalImportReceiptsTableOrderingComposer, - $$IcalImportReceiptsTableAnnotationComposer, - $$IcalImportReceiptsTableCreateCompanionBuilder, - $$IcalImportReceiptsTableUpdateCompanionBuilder, - (IcalImportReceipt, $$IcalImportReceiptsTableReferences), - IcalImportReceipt, - PrefetchHooks Function({bool calendarSourceId}) + $WebCalSubscriptionsTable, + WebCalSubscription, + $$WebCalSubscriptionsTableFilterComposer, + $$WebCalSubscriptionsTableOrderingComposer, + $$WebCalSubscriptionsTableAnnotationComposer, + $$WebCalSubscriptionsTableCreateCompanionBuilder, + $$WebCalSubscriptionsTableUpdateCompanionBuilder, + (WebCalSubscription, $$WebCalSubscriptionsTableReferences), + WebCalSubscription, + PrefetchHooks Function({bool accountId, bool calendarSourceId}) > { - $$IcalImportReceiptsTableTableManager( + $$WebCalSubscriptionsTableTableManager( _$AppDatabase db, - $IcalImportReceiptsTable table, + $WebCalSubscriptionsTable table, ) : super( TableManagerState( db: db, table: table, createFilteringComposer: () => - $$IcalImportReceiptsTableFilterComposer($db: db, $table: table), + $$WebCalSubscriptionsTableFilterComposer($db: db, $table: table), createOrderingComposer: () => - $$IcalImportReceiptsTableOrderingComposer($db: db, $table: table), + $$WebCalSubscriptionsTableOrderingComposer( + $db: db, + $table: table, + ), createComputedFieldComposer: () => - $$IcalImportReceiptsTableAnnotationComposer( + $$WebCalSubscriptionsTableAnnotationComposer( $db: db, $table: table, ), updateCompanionCallback: ({ + Value id = const Value.absent(), + Value accountId = const Value.absent(), Value calendarSourceId = const Value.absent(), - Value icalUid = const Value.absent(), - Value eventId = const Value.absent(), - Value importedAtUtc = const Value.absent(), + Value feedFingerprint = const Value.absent(), + Value safeOrigin = const Value.absent(), + Value validatorTargetFingerprint = + const Value.absent(), + Value etag = const Value.absent(), + Value lastModified = const Value.absent(), + Value contentType = const Value.absent(), + Value snapshotIcsBody = const Value.absent(), + Value rawBodyHash = const Value.absent(), + Value semanticHash = const Value.absent(), + Value refreshMode = const Value.absent(), + Value serverRefreshIntervalSeconds = const Value.absent(), + Value nextRefreshAtUtc = const Value.absent(), + Value lastCheckedAtUtc = const Value.absent(), + Value lastSuccessfulSyncAtUtc = const Value.absent(), + Value lastChangedAtUtc = const Value.absent(), + Value consecutiveFailureCount = const Value.absent(), + Value lastFailureCode = const Value.absent(), + Value lastFailureHttpStatus = const Value.absent(), + Value generation = const Value.absent(), + Value parserVersion = const Value.absent(), + Value projectionVersion = const Value.absent(), + Value projectionRangeStartUtc = const Value.absent(), + Value projectionRangeEndUtc = const Value.absent(), + Value createdAtUtc = const Value.absent(), + Value updatedAtUtc = const Value.absent(), Value rowid = const Value.absent(), - }) => IcalImportReceiptsCompanion( + }) => WebCalSubscriptionsCompanion( + id: id, + accountId: accountId, calendarSourceId: calendarSourceId, - icalUid: icalUid, - eventId: eventId, - importedAtUtc: importedAtUtc, + feedFingerprint: feedFingerprint, + safeOrigin: safeOrigin, + validatorTargetFingerprint: validatorTargetFingerprint, + etag: etag, + lastModified: lastModified, + contentType: contentType, + snapshotIcsBody: snapshotIcsBody, + rawBodyHash: rawBodyHash, + semanticHash: semanticHash, + refreshMode: refreshMode, + serverRefreshIntervalSeconds: serverRefreshIntervalSeconds, + nextRefreshAtUtc: nextRefreshAtUtc, + lastCheckedAtUtc: lastCheckedAtUtc, + lastSuccessfulSyncAtUtc: lastSuccessfulSyncAtUtc, + lastChangedAtUtc: lastChangedAtUtc, + consecutiveFailureCount: consecutiveFailureCount, + lastFailureCode: lastFailureCode, + lastFailureHttpStatus: lastFailureHttpStatus, + generation: generation, + parserVersion: parserVersion, + projectionVersion: projectionVersion, + projectionRangeStartUtc: projectionRangeStartUtc, + projectionRangeEndUtc: projectionRangeEndUtc, + createdAtUtc: createdAtUtc, + updatedAtUtc: updatedAtUtc, rowid: rowid, ), createCompanionCallback: ({ + required String id, + required String accountId, required String calendarSourceId, - required String icalUid, - Value eventId = const Value.absent(), - required String importedAtUtc, + required String feedFingerprint, + required String safeOrigin, + Value validatorTargetFingerprint = + const Value.absent(), + Value etag = const Value.absent(), + Value lastModified = const Value.absent(), + Value contentType = const Value.absent(), + required String snapshotIcsBody, + required String rawBodyHash, + required String semanticHash, + required String refreshMode, + Value serverRefreshIntervalSeconds = const Value.absent(), + required String nextRefreshAtUtc, + Value lastCheckedAtUtc = const Value.absent(), + Value lastSuccessfulSyncAtUtc = const Value.absent(), + Value lastChangedAtUtc = const Value.absent(), + Value consecutiveFailureCount = const Value.absent(), + Value lastFailureCode = const Value.absent(), + Value lastFailureHttpStatus = const Value.absent(), + Value generation = const Value.absent(), + Value parserVersion = const Value.absent(), + Value projectionVersion = const Value.absent(), + Value projectionRangeStartUtc = const Value.absent(), + Value projectionRangeEndUtc = const Value.absent(), + required String createdAtUtc, + required String updatedAtUtc, Value rowid = const Value.absent(), - }) => IcalImportReceiptsCompanion.insert( + }) => WebCalSubscriptionsCompanion.insert( + id: id, + accountId: accountId, calendarSourceId: calendarSourceId, - icalUid: icalUid, - eventId: eventId, - importedAtUtc: importedAtUtc, + feedFingerprint: feedFingerprint, + safeOrigin: safeOrigin, + validatorTargetFingerprint: validatorTargetFingerprint, + etag: etag, + lastModified: lastModified, + contentType: contentType, + snapshotIcsBody: snapshotIcsBody, + rawBodyHash: rawBodyHash, + semanticHash: semanticHash, + refreshMode: refreshMode, + serverRefreshIntervalSeconds: serverRefreshIntervalSeconds, + nextRefreshAtUtc: nextRefreshAtUtc, + lastCheckedAtUtc: lastCheckedAtUtc, + lastSuccessfulSyncAtUtc: lastSuccessfulSyncAtUtc, + lastChangedAtUtc: lastChangedAtUtc, + consecutiveFailureCount: consecutiveFailureCount, + lastFailureCode: lastFailureCode, + lastFailureHttpStatus: lastFailureHttpStatus, + generation: generation, + parserVersion: parserVersion, + projectionVersion: projectionVersion, + projectionRangeStartUtc: projectionRangeStartUtc, + projectionRangeEndUtc: projectionRangeEndUtc, + createdAtUtc: createdAtUtc, + updatedAtUtc: updatedAtUtc, rowid: rowid, ), withReferenceMapper: (p0) => p0 .map( (e) => ( - e.readTable(table), - $$IcalImportReceiptsTableReferences(db, table, e), + e.readTable<$WebCalSubscriptionsTable, WebCalSubscription>( + table, + ), + $$WebCalSubscriptionsTableReferences(db, table, e), ), ) .toList(), - prefetchHooksCallback: ({calendarSourceId = false}) { - return PrefetchHooks( - db: db, - explicitlyWatchedTables: [], - addJoins: - < - T extends TableManagerState< - dynamic, - dynamic, - dynamic, - dynamic, - dynamic, - dynamic, - dynamic, - dynamic, - dynamic, - dynamic, - dynamic - > - >(state) { - if (calendarSourceId) { - state = - state.withJoin( - currentTable: table, - currentColumn: table.calendarSourceId, - referencedTable: - $$IcalImportReceiptsTableReferences - ._calendarSourceIdTable(db), - referencedColumn: - $$IcalImportReceiptsTableReferences - ._calendarSourceIdTable(db) - .id, - ) - as T; - } + prefetchHooksCallback: + ({accountId = false, calendarSourceId = false}) { + return PrefetchHooks( + db: db, + explicitlyWatchedTables: [], + addJoins: + < + T extends TableManagerState< + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic + > + >(state) { + if (accountId) { + state = + state.withJoin( + currentTable: table, + currentColumn: table.accountId, + referencedTable: + $$WebCalSubscriptionsTableReferences + ._accountIdTable(db), + referencedColumn: + $$WebCalSubscriptionsTableReferences + ._accountIdTable(db) + .id, + ) + as T; + } + if (calendarSourceId) { + state = + state.withJoin( + currentTable: table, + currentColumn: table.calendarSourceId, + referencedTable: + $$WebCalSubscriptionsTableReferences + ._calendarSourceIdTable(db), + referencedColumn: + $$WebCalSubscriptionsTableReferences + ._calendarSourceIdTable(db) + .id, + ) + as T; + } - return state; + return state; + }, + getPrefetchedDataCallback: (items) async { + return []; }, - getPrefetchedDataCallback: (items) async { - return []; + ); }, - ); - }, ), ); } -typedef $$IcalImportReceiptsTableProcessedTableManager = +typedef $$WebCalSubscriptionsTableProcessedTableManager = ProcessedTableManager< _$AppDatabase, - $IcalImportReceiptsTable, - IcalImportReceipt, - $$IcalImportReceiptsTableFilterComposer, - $$IcalImportReceiptsTableOrderingComposer, - $$IcalImportReceiptsTableAnnotationComposer, - $$IcalImportReceiptsTableCreateCompanionBuilder, - $$IcalImportReceiptsTableUpdateCompanionBuilder, - (IcalImportReceipt, $$IcalImportReceiptsTableReferences), - IcalImportReceipt, - PrefetchHooks Function({bool calendarSourceId}) + $WebCalSubscriptionsTable, + WebCalSubscription, + $$WebCalSubscriptionsTableFilterComposer, + $$WebCalSubscriptionsTableOrderingComposer, + $$WebCalSubscriptionsTableAnnotationComposer, + $$WebCalSubscriptionsTableCreateCompanionBuilder, + $$WebCalSubscriptionsTableUpdateCompanionBuilder, + (WebCalSubscription, $$WebCalSubscriptionsTableReferences), + WebCalSubscription, + PrefetchHooks Function({bool accountId, bool calendarSourceId}) >; -typedef $$WebCalSubscriptionsTableCreateCompanionBuilder = - WebCalSubscriptionsCompanion Function({ +typedef $$CalendarEventAttendeesTableCreateCompanionBuilder = + CalendarEventAttendeesCompanion Function({ required String id, - required String accountId, - required String calendarSourceId, - required String feedFingerprint, - required String safeOrigin, - Value validatorTargetFingerprint, - Value etag, - Value lastModified, - Value contentType, - required String snapshotIcsBody, - required String rawBodyHash, - required String semanticHash, - required String refreshMode, - Value serverRefreshIntervalSeconds, - required String nextRefreshAtUtc, - Value lastCheckedAtUtc, - Value lastSuccessfulSyncAtUtc, - Value lastChangedAtUtc, - Value consecutiveFailureCount, - Value lastFailureCode, - Value lastFailureHttpStatus, - Value generation, - Value parserVersion, - Value projectionVersion, - Value projectionRangeStartUtc, - Value projectionRangeEndUtc, - required String createdAtUtc, - required String updatedAtUtc, + required String calendarEventId, + required String email, + Value displayName, + Value responseStatus, + Value optional, + Value organizer, + Value self, + Value rawJson, Value rowid, }); -typedef $$WebCalSubscriptionsTableUpdateCompanionBuilder = - WebCalSubscriptionsCompanion Function({ +typedef $$CalendarEventAttendeesTableUpdateCompanionBuilder = + CalendarEventAttendeesCompanion Function({ Value id, - Value accountId, - Value calendarSourceId, - Value feedFingerprint, - Value safeOrigin, - Value validatorTargetFingerprint, - Value etag, - Value lastModified, - Value contentType, - Value snapshotIcsBody, - Value rawBodyHash, - Value semanticHash, - Value refreshMode, - Value serverRefreshIntervalSeconds, - Value nextRefreshAtUtc, - Value lastCheckedAtUtc, - Value lastSuccessfulSyncAtUtc, - Value lastChangedAtUtc, - Value consecutiveFailureCount, - Value lastFailureCode, - Value lastFailureHttpStatus, - Value generation, - Value parserVersion, - Value projectionVersion, - Value projectionRangeStartUtc, - Value projectionRangeEndUtc, - Value createdAtUtc, - Value updatedAtUtc, + Value calendarEventId, + Value email, + Value displayName, + Value responseStatus, + Value optional, + Value organizer, + Value self, + Value rawJson, Value rowid, }); -final class $$WebCalSubscriptionsTableReferences +final class $$CalendarEventAttendeesTableReferences extends BaseReferences< _$AppDatabase, - $WebCalSubscriptionsTable, - WebCalSubscription + $CalendarEventAttendeesTable, + CalendarEventAttendee > { - $$WebCalSubscriptionsTableReferences( + $$CalendarEventAttendeesTableReferences( super.$_db, super.$_table, super.$_typedResult, ); - static $AccountsTable _accountIdTable(_$AppDatabase db) => - db.accounts.createAlias( - $_aliasNameGenerator(db.webCalSubscriptions.accountId, db.accounts.id), - ); - - $$AccountsTableProcessedTableManager get accountId { - final $_column = $_itemColumn('account_id')!; - - final manager = $$AccountsTableTableManager( - $_db, - $_db.accounts, - ).filter((f) => f.id.sqlEquals($_column)); - final item = $_typedResult.readTableOrNull(_accountIdTable($_db)); - if (item == null) return manager; - return ProcessedTableManager( - manager.$state.copyWith(prefetchedData: [item]), - ); - } - - static $CalendarSourcesTable _calendarSourceIdTable(_$AppDatabase db) => - db.calendarSources.createAlias( - $_aliasNameGenerator( - db.webCalSubscriptions.calendarSourceId, - db.calendarSources.id, - ), + static $CalendarEventsTable _calendarEventIdTable(_$AppDatabase db) => + db.calendarEvents.createAlias( + 'calendar_event_attendees__calendar_event_id__calendar_events__id', ); - $$CalendarSourcesTableProcessedTableManager get calendarSourceId { - final $_column = $_itemColumn('calendar_source_id')!; + $$CalendarEventsTableProcessedTableManager get calendarEventId { + final $_column = $_itemColumn('calendar_event_id')!; - final manager = $$CalendarSourcesTableTableManager( + final manager = $$CalendarEventsTableTableManager( $_db, - $_db.calendarSources, + $_db.calendarEvents, ).filter((f) => f.id.sqlEquals($_column)); - final item = $_typedResult.readTableOrNull(_calendarSourceIdTable($_db)); + final item = $_typedResult.readTableOrNull(_calendarEventIdTable($_db)); if (item == null) return manager; return ProcessedTableManager( manager.$state.copyWith(prefetchedData: [item]), @@ -41034,9 +42698,9 @@ final class $$WebCalSubscriptionsTableReferences } } -class $$WebCalSubscriptionsTableFilterComposer - extends Composer<_$AppDatabase, $WebCalSubscriptionsTable> { - $$WebCalSubscriptionsTableFilterComposer({ +class $$CalendarEventAttendeesTableFilterComposer + extends Composer<_$AppDatabase, $CalendarEventAttendeesTable> { + $$CalendarEventAttendeesTableFilterComposer({ required super.$db, required super.$table, super.joinBuilder, @@ -41048,168 +42712,55 @@ class $$WebCalSubscriptionsTableFilterComposer builder: (column) => ColumnFilters(column), ); - ColumnFilters get feedFingerprint => $composableBuilder( - column: $table.feedFingerprint, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get safeOrigin => $composableBuilder( - column: $table.safeOrigin, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get validatorTargetFingerprint => $composableBuilder( - column: $table.validatorTargetFingerprint, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get etag => $composableBuilder( - column: $table.etag, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get lastModified => $composableBuilder( - column: $table.lastModified, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get contentType => $composableBuilder( - column: $table.contentType, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get snapshotIcsBody => $composableBuilder( - column: $table.snapshotIcsBody, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get rawBodyHash => $composableBuilder( - column: $table.rawBodyHash, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get semanticHash => $composableBuilder( - column: $table.semanticHash, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get refreshMode => $composableBuilder( - column: $table.refreshMode, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get serverRefreshIntervalSeconds => $composableBuilder( - column: $table.serverRefreshIntervalSeconds, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get nextRefreshAtUtc => $composableBuilder( - column: $table.nextRefreshAtUtc, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get lastCheckedAtUtc => $composableBuilder( - column: $table.lastCheckedAtUtc, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get lastSuccessfulSyncAtUtc => $composableBuilder( - column: $table.lastSuccessfulSyncAtUtc, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get lastChangedAtUtc => $composableBuilder( - column: $table.lastChangedAtUtc, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get consecutiveFailureCount => $composableBuilder( - column: $table.consecutiveFailureCount, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get lastFailureCode => $composableBuilder( - column: $table.lastFailureCode, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get lastFailureHttpStatus => $composableBuilder( - column: $table.lastFailureHttpStatus, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get generation => $composableBuilder( - column: $table.generation, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get parserVersion => $composableBuilder( - column: $table.parserVersion, + ColumnFilters get email => $composableBuilder( + column: $table.email, builder: (column) => ColumnFilters(column), ); - ColumnFilters get projectionVersion => $composableBuilder( - column: $table.projectionVersion, + ColumnFilters get displayName => $composableBuilder( + column: $table.displayName, builder: (column) => ColumnFilters(column), ); - ColumnFilters get projectionRangeStartUtc => $composableBuilder( - column: $table.projectionRangeStartUtc, + ColumnFilters get responseStatus => $composableBuilder( + column: $table.responseStatus, builder: (column) => ColumnFilters(column), ); - ColumnFilters get projectionRangeEndUtc => $composableBuilder( - column: $table.projectionRangeEndUtc, + ColumnFilters get optional => $composableBuilder( + column: $table.optional, builder: (column) => ColumnFilters(column), ); - ColumnFilters get createdAtUtc => $composableBuilder( - column: $table.createdAtUtc, + ColumnFilters get organizer => $composableBuilder( + column: $table.organizer, builder: (column) => ColumnFilters(column), ); - ColumnFilters get updatedAtUtc => $composableBuilder( - column: $table.updatedAtUtc, + ColumnFilters get self => $composableBuilder( + column: $table.self, builder: (column) => ColumnFilters(column), ); - $$AccountsTableFilterComposer get accountId { - final $$AccountsTableFilterComposer composer = $composerBuilder( - composer: this, - getCurrentColumn: (t) => t.accountId, - referencedTable: $db.accounts, - getReferencedColumn: (t) => t.id, - builder: - ( - joinBuilder, { - $addJoinBuilderToRootComposer, - $removeJoinBuilderFromRootComposer, - }) => $$AccountsTableFilterComposer( - $db: $db, - $table: $db.accounts, - $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, - joinBuilder: joinBuilder, - $removeJoinBuilderFromRootComposer: - $removeJoinBuilderFromRootComposer, - ), - ); - return composer; - } + ColumnFilters get rawJson => $composableBuilder( + column: $table.rawJson, + builder: (column) => ColumnFilters(column), + ); - $$CalendarSourcesTableFilterComposer get calendarSourceId { - final $$CalendarSourcesTableFilterComposer composer = $composerBuilder( + $$CalendarEventsTableFilterComposer get calendarEventId { + final $$CalendarEventsTableFilterComposer composer = $composerBuilder( composer: this, - getCurrentColumn: (t) => t.calendarSourceId, - referencedTable: $db.calendarSources, + getCurrentColumn: (t) => t.calendarEventId, + referencedTable: $db.calendarEvents, getReferencedColumn: (t) => t.id, builder: ( joinBuilder, { $addJoinBuilderToRootComposer, $removeJoinBuilderFromRootComposer, - }) => $$CalendarSourcesTableFilterComposer( + }) => $$CalendarEventsTableFilterComposer( $db: $db, - $table: $db.calendarSources, + $table: $db.calendarEvents, $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, joinBuilder: joinBuilder, $removeJoinBuilderFromRootComposer: @@ -41220,9 +42771,9 @@ class $$WebCalSubscriptionsTableFilterComposer } } -class $$WebCalSubscriptionsTableOrderingComposer - extends Composer<_$AppDatabase, $WebCalSubscriptionsTable> { - $$WebCalSubscriptionsTableOrderingComposer({ +class $$CalendarEventAttendeesTableOrderingComposer + extends Composer<_$AppDatabase, $CalendarEventAttendeesTable> { + $$CalendarEventAttendeesTableOrderingComposer({ required super.$db, required super.$table, super.joinBuilder, @@ -41234,168 +42785,55 @@ class $$WebCalSubscriptionsTableOrderingComposer builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get feedFingerprint => $composableBuilder( - column: $table.feedFingerprint, - builder: (column) => ColumnOrderings(column), - ); - - ColumnOrderings get safeOrigin => $composableBuilder( - column: $table.safeOrigin, - builder: (column) => ColumnOrderings(column), - ); - - ColumnOrderings get validatorTargetFingerprint => $composableBuilder( - column: $table.validatorTargetFingerprint, - builder: (column) => ColumnOrderings(column), - ); - - ColumnOrderings get etag => $composableBuilder( - column: $table.etag, - builder: (column) => ColumnOrderings(column), - ); - - ColumnOrderings get lastModified => $composableBuilder( - column: $table.lastModified, - builder: (column) => ColumnOrderings(column), - ); - - ColumnOrderings get contentType => $composableBuilder( - column: $table.contentType, - builder: (column) => ColumnOrderings(column), - ); - - ColumnOrderings get snapshotIcsBody => $composableBuilder( - column: $table.snapshotIcsBody, - builder: (column) => ColumnOrderings(column), - ); - - ColumnOrderings get rawBodyHash => $composableBuilder( - column: $table.rawBodyHash, - builder: (column) => ColumnOrderings(column), - ); - - ColumnOrderings get semanticHash => $composableBuilder( - column: $table.semanticHash, - builder: (column) => ColumnOrderings(column), - ); - - ColumnOrderings get refreshMode => $composableBuilder( - column: $table.refreshMode, - builder: (column) => ColumnOrderings(column), - ); - - ColumnOrderings get serverRefreshIntervalSeconds => $composableBuilder( - column: $table.serverRefreshIntervalSeconds, - builder: (column) => ColumnOrderings(column), - ); - - ColumnOrderings get nextRefreshAtUtc => $composableBuilder( - column: $table.nextRefreshAtUtc, - builder: (column) => ColumnOrderings(column), - ); - - ColumnOrderings get lastCheckedAtUtc => $composableBuilder( - column: $table.lastCheckedAtUtc, - builder: (column) => ColumnOrderings(column), - ); - - ColumnOrderings get lastSuccessfulSyncAtUtc => $composableBuilder( - column: $table.lastSuccessfulSyncAtUtc, - builder: (column) => ColumnOrderings(column), - ); - - ColumnOrderings get lastChangedAtUtc => $composableBuilder( - column: $table.lastChangedAtUtc, - builder: (column) => ColumnOrderings(column), - ); - - ColumnOrderings get consecutiveFailureCount => $composableBuilder( - column: $table.consecutiveFailureCount, - builder: (column) => ColumnOrderings(column), - ); - - ColumnOrderings get lastFailureCode => $composableBuilder( - column: $table.lastFailureCode, - builder: (column) => ColumnOrderings(column), - ); - - ColumnOrderings get lastFailureHttpStatus => $composableBuilder( - column: $table.lastFailureHttpStatus, - builder: (column) => ColumnOrderings(column), - ); - - ColumnOrderings get generation => $composableBuilder( - column: $table.generation, + ColumnOrderings get email => $composableBuilder( + column: $table.email, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get parserVersion => $composableBuilder( - column: $table.parserVersion, + ColumnOrderings get displayName => $composableBuilder( + column: $table.displayName, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get projectionVersion => $composableBuilder( - column: $table.projectionVersion, + ColumnOrderings get responseStatus => $composableBuilder( + column: $table.responseStatus, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get projectionRangeStartUtc => $composableBuilder( - column: $table.projectionRangeStartUtc, + ColumnOrderings get optional => $composableBuilder( + column: $table.optional, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get projectionRangeEndUtc => $composableBuilder( - column: $table.projectionRangeEndUtc, + ColumnOrderings get organizer => $composableBuilder( + column: $table.organizer, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get createdAtUtc => $composableBuilder( - column: $table.createdAtUtc, + ColumnOrderings get self => $composableBuilder( + column: $table.self, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get updatedAtUtc => $composableBuilder( - column: $table.updatedAtUtc, + ColumnOrderings get rawJson => $composableBuilder( + column: $table.rawJson, builder: (column) => ColumnOrderings(column), ); - $$AccountsTableOrderingComposer get accountId { - final $$AccountsTableOrderingComposer composer = $composerBuilder( - composer: this, - getCurrentColumn: (t) => t.accountId, - referencedTable: $db.accounts, - getReferencedColumn: (t) => t.id, - builder: - ( - joinBuilder, { - $addJoinBuilderToRootComposer, - $removeJoinBuilderFromRootComposer, - }) => $$AccountsTableOrderingComposer( - $db: $db, - $table: $db.accounts, - $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, - joinBuilder: joinBuilder, - $removeJoinBuilderFromRootComposer: - $removeJoinBuilderFromRootComposer, - ), - ); - return composer; - } - - $$CalendarSourcesTableOrderingComposer get calendarSourceId { - final $$CalendarSourcesTableOrderingComposer composer = $composerBuilder( + $$CalendarEventsTableOrderingComposer get calendarEventId { + final $$CalendarEventsTableOrderingComposer composer = $composerBuilder( composer: this, - getCurrentColumn: (t) => t.calendarSourceId, - referencedTable: $db.calendarSources, + getCurrentColumn: (t) => t.calendarEventId, + referencedTable: $db.calendarEvents, getReferencedColumn: (t) => t.id, builder: ( joinBuilder, { $addJoinBuilderToRootComposer, $removeJoinBuilderFromRootComposer, - }) => $$CalendarSourcesTableOrderingComposer( + }) => $$CalendarEventsTableOrderingComposer( $db: $db, - $table: $db.calendarSources, + $table: $db.calendarEvents, $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, joinBuilder: joinBuilder, $removeJoinBuilderFromRootComposer: @@ -41406,9 +42844,9 @@ class $$WebCalSubscriptionsTableOrderingComposer } } -class $$WebCalSubscriptionsTableAnnotationComposer - extends Composer<_$AppDatabase, $WebCalSubscriptionsTable> { - $$WebCalSubscriptionsTableAnnotationComposer({ +class $$CalendarEventAttendeesTableAnnotationComposer + extends Composer<_$AppDatabase, $CalendarEventAttendeesTable> { + $$CalendarEventAttendeesTableAnnotationComposer({ required super.$db, required super.$table, super.joinBuilder, @@ -41418,143 +42856,396 @@ class $$WebCalSubscriptionsTableAnnotationComposer GeneratedColumn get id => $composableBuilder(column: $table.id, builder: (column) => column); - GeneratedColumn get feedFingerprint => $composableBuilder( - column: $table.feedFingerprint, - builder: (column) => column, - ); - - GeneratedColumn get safeOrigin => $composableBuilder( - column: $table.safeOrigin, - builder: (column) => column, - ); - - GeneratedColumn get validatorTargetFingerprint => $composableBuilder( - column: $table.validatorTargetFingerprint, - builder: (column) => column, - ); - - GeneratedColumn get etag => - $composableBuilder(column: $table.etag, builder: (column) => column); - - GeneratedColumn get lastModified => $composableBuilder( - column: $table.lastModified, - builder: (column) => column, - ); - - GeneratedColumn get contentType => $composableBuilder( - column: $table.contentType, - builder: (column) => column, - ); + GeneratedColumn get email => + $composableBuilder(column: $table.email, builder: (column) => column); - GeneratedColumn get snapshotIcsBody => $composableBuilder( - column: $table.snapshotIcsBody, + GeneratedColumn get displayName => $composableBuilder( + column: $table.displayName, builder: (column) => column, ); - GeneratedColumn get rawBodyHash => $composableBuilder( - column: $table.rawBodyHash, + GeneratedColumn get responseStatus => $composableBuilder( + column: $table.responseStatus, builder: (column) => column, ); - GeneratedColumn get semanticHash => $composableBuilder( - column: $table.semanticHash, - builder: (column) => column, - ); + GeneratedColumn get optional => + $composableBuilder(column: $table.optional, builder: (column) => column); - GeneratedColumn get refreshMode => $composableBuilder( - column: $table.refreshMode, - builder: (column) => column, - ); + GeneratedColumn get organizer => + $composableBuilder(column: $table.organizer, builder: (column) => column); - GeneratedColumn get serverRefreshIntervalSeconds => $composableBuilder( - column: $table.serverRefreshIntervalSeconds, - builder: (column) => column, + GeneratedColumn get self => + $composableBuilder(column: $table.self, builder: (column) => column); + + GeneratedColumn get rawJson => + $composableBuilder(column: $table.rawJson, builder: (column) => column); + + $$CalendarEventsTableAnnotationComposer get calendarEventId { + final $$CalendarEventsTableAnnotationComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.calendarEventId, + referencedTable: $db.calendarEvents, + getReferencedColumn: (t) => t.id, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$CalendarEventsTableAnnotationComposer( + $db: $db, + $table: $db.calendarEvents, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return composer; + } +} + +class $$CalendarEventAttendeesTableTableManager + extends + RootTableManager< + _$AppDatabase, + $CalendarEventAttendeesTable, + CalendarEventAttendee, + $$CalendarEventAttendeesTableFilterComposer, + $$CalendarEventAttendeesTableOrderingComposer, + $$CalendarEventAttendeesTableAnnotationComposer, + $$CalendarEventAttendeesTableCreateCompanionBuilder, + $$CalendarEventAttendeesTableUpdateCompanionBuilder, + (CalendarEventAttendee, $$CalendarEventAttendeesTableReferences), + CalendarEventAttendee, + PrefetchHooks Function({bool calendarEventId}) + > { + $$CalendarEventAttendeesTableTableManager( + _$AppDatabase db, + $CalendarEventAttendeesTable table, + ) : super( + TableManagerState( + db: db, + table: table, + createFilteringComposer: () => + $$CalendarEventAttendeesTableFilterComposer( + $db: db, + $table: table, + ), + createOrderingComposer: () => + $$CalendarEventAttendeesTableOrderingComposer( + $db: db, + $table: table, + ), + createComputedFieldComposer: () => + $$CalendarEventAttendeesTableAnnotationComposer( + $db: db, + $table: table, + ), + updateCompanionCallback: + ({ + Value id = const Value.absent(), + Value calendarEventId = const Value.absent(), + Value email = const Value.absent(), + Value displayName = const Value.absent(), + Value responseStatus = const Value.absent(), + Value optional = const Value.absent(), + Value organizer = const Value.absent(), + Value self = const Value.absent(), + Value rawJson = const Value.absent(), + Value rowid = const Value.absent(), + }) => CalendarEventAttendeesCompanion( + id: id, + calendarEventId: calendarEventId, + email: email, + displayName: displayName, + responseStatus: responseStatus, + optional: optional, + organizer: organizer, + self: self, + rawJson: rawJson, + rowid: rowid, + ), + createCompanionCallback: + ({ + required String id, + required String calendarEventId, + required String email, + Value displayName = const Value.absent(), + Value responseStatus = const Value.absent(), + Value optional = const Value.absent(), + Value organizer = const Value.absent(), + Value self = const Value.absent(), + Value rawJson = const Value.absent(), + Value rowid = const Value.absent(), + }) => CalendarEventAttendeesCompanion.insert( + id: id, + calendarEventId: calendarEventId, + email: email, + displayName: displayName, + responseStatus: responseStatus, + optional: optional, + organizer: organizer, + self: self, + rawJson: rawJson, + rowid: rowid, + ), + withReferenceMapper: (p0) => p0 + .map( + (e) => ( + e.readTable< + $CalendarEventAttendeesTable, + CalendarEventAttendee + >(table), + $$CalendarEventAttendeesTableReferences(db, table, e), + ), + ) + .toList(), + prefetchHooksCallback: ({calendarEventId = false}) { + return PrefetchHooks( + db: db, + explicitlyWatchedTables: [], + addJoins: + < + T extends TableManagerState< + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic + > + >(state) { + if (calendarEventId) { + state = + state.withJoin( + currentTable: table, + currentColumn: table.calendarEventId, + referencedTable: + $$CalendarEventAttendeesTableReferences + ._calendarEventIdTable(db), + referencedColumn: + $$CalendarEventAttendeesTableReferences + ._calendarEventIdTable(db) + .id, + ) + as T; + } + + return state; + }, + getPrefetchedDataCallback: (items) async { + return []; + }, + ); + }, + ), + ); +} + +typedef $$CalendarEventAttendeesTableProcessedTableManager = + ProcessedTableManager< + _$AppDatabase, + $CalendarEventAttendeesTable, + CalendarEventAttendee, + $$CalendarEventAttendeesTableFilterComposer, + $$CalendarEventAttendeesTableOrderingComposer, + $$CalendarEventAttendeesTableAnnotationComposer, + $$CalendarEventAttendeesTableCreateCompanionBuilder, + $$CalendarEventAttendeesTableUpdateCompanionBuilder, + (CalendarEventAttendee, $$CalendarEventAttendeesTableReferences), + CalendarEventAttendee, + PrefetchHooks Function({bool calendarEventId}) + >; +typedef $$CalendarEventRemindersTableCreateCompanionBuilder = + CalendarEventRemindersCompanion Function({ + required String id, + required String calendarEventId, + required String provider, + Value method, + Value minutesBefore, + Value absoluteTime, + Value enabled, + Value rawJson, + Value rowid, + }); +typedef $$CalendarEventRemindersTableUpdateCompanionBuilder = + CalendarEventRemindersCompanion Function({ + Value id, + Value calendarEventId, + Value provider, + Value method, + Value minutesBefore, + Value absoluteTime, + Value enabled, + Value rawJson, + Value rowid, + }); + +final class $$CalendarEventRemindersTableReferences + extends + BaseReferences< + _$AppDatabase, + $CalendarEventRemindersTable, + CalendarEventReminder + > { + $$CalendarEventRemindersTableReferences( + super.$_db, + super.$_table, + super.$_typedResult, ); - GeneratedColumn get nextRefreshAtUtc => $composableBuilder( - column: $table.nextRefreshAtUtc, - builder: (column) => column, + static $CalendarEventsTable _calendarEventIdTable(_$AppDatabase db) => + db.calendarEvents.createAlias( + 'calendar_event_reminders__calendar_event_id__calendar_events__id', + ); + + $$CalendarEventsTableProcessedTableManager get calendarEventId { + final $_column = $_itemColumn('calendar_event_id')!; + + final manager = $$CalendarEventsTableTableManager( + $_db, + $_db.calendarEvents, + ).filter((f) => f.id.sqlEquals($_column)); + final item = $_typedResult.readTableOrNull(_calendarEventIdTable($_db)); + if (item == null) return manager; + return ProcessedTableManager( + manager.$state.copyWith(prefetchedData: [item]), + ); + } +} + +class $$CalendarEventRemindersTableFilterComposer + extends Composer<_$AppDatabase, $CalendarEventRemindersTable> { + $$CalendarEventRemindersTableFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnFilters get id => $composableBuilder( + column: $table.id, + builder: (column) => ColumnFilters(column), ); - GeneratedColumn get lastCheckedAtUtc => $composableBuilder( - column: $table.lastCheckedAtUtc, - builder: (column) => column, + ColumnFilters get provider => $composableBuilder( + column: $table.provider, + builder: (column) => ColumnFilters(column), ); - GeneratedColumn get lastSuccessfulSyncAtUtc => $composableBuilder( - column: $table.lastSuccessfulSyncAtUtc, - builder: (column) => column, + ColumnFilters get method => $composableBuilder( + column: $table.method, + builder: (column) => ColumnFilters(column), ); - GeneratedColumn get lastChangedAtUtc => $composableBuilder( - column: $table.lastChangedAtUtc, - builder: (column) => column, + ColumnFilters get minutesBefore => $composableBuilder( + column: $table.minutesBefore, + builder: (column) => ColumnFilters(column), ); - GeneratedColumn get consecutiveFailureCount => $composableBuilder( - column: $table.consecutiveFailureCount, - builder: (column) => column, + ColumnFilters get absoluteTime => $composableBuilder( + column: $table.absoluteTime, + builder: (column) => ColumnFilters(column), ); - GeneratedColumn get lastFailureCode => $composableBuilder( - column: $table.lastFailureCode, - builder: (column) => column, + ColumnFilters get enabled => $composableBuilder( + column: $table.enabled, + builder: (column) => ColumnFilters(column), ); - GeneratedColumn get lastFailureHttpStatus => $composableBuilder( - column: $table.lastFailureHttpStatus, - builder: (column) => column, + ColumnFilters get rawJson => $composableBuilder( + column: $table.rawJson, + builder: (column) => ColumnFilters(column), ); - GeneratedColumn get generation => $composableBuilder( - column: $table.generation, - builder: (column) => column, + $$CalendarEventsTableFilterComposer get calendarEventId { + final $$CalendarEventsTableFilterComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.calendarEventId, + referencedTable: $db.calendarEvents, + getReferencedColumn: (t) => t.id, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$CalendarEventsTableFilterComposer( + $db: $db, + $table: $db.calendarEvents, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return composer; + } +} + +class $$CalendarEventRemindersTableOrderingComposer + extends Composer<_$AppDatabase, $CalendarEventRemindersTable> { + $$CalendarEventRemindersTableOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnOrderings get id => $composableBuilder( + column: $table.id, + builder: (column) => ColumnOrderings(column), ); - GeneratedColumn get parserVersion => $composableBuilder( - column: $table.parserVersion, - builder: (column) => column, + ColumnOrderings get provider => $composableBuilder( + column: $table.provider, + builder: (column) => ColumnOrderings(column), ); - GeneratedColumn get projectionVersion => $composableBuilder( - column: $table.projectionVersion, - builder: (column) => column, + ColumnOrderings get method => $composableBuilder( + column: $table.method, + builder: (column) => ColumnOrderings(column), ); - GeneratedColumn get projectionRangeStartUtc => $composableBuilder( - column: $table.projectionRangeStartUtc, - builder: (column) => column, + ColumnOrderings get minutesBefore => $composableBuilder( + column: $table.minutesBefore, + builder: (column) => ColumnOrderings(column), ); - GeneratedColumn get projectionRangeEndUtc => $composableBuilder( - column: $table.projectionRangeEndUtc, - builder: (column) => column, + ColumnOrderings get absoluteTime => $composableBuilder( + column: $table.absoluteTime, + builder: (column) => ColumnOrderings(column), ); - GeneratedColumn get createdAtUtc => $composableBuilder( - column: $table.createdAtUtc, - builder: (column) => column, + ColumnOrderings get enabled => $composableBuilder( + column: $table.enabled, + builder: (column) => ColumnOrderings(column), ); - GeneratedColumn get updatedAtUtc => $composableBuilder( - column: $table.updatedAtUtc, - builder: (column) => column, + ColumnOrderings get rawJson => $composableBuilder( + column: $table.rawJson, + builder: (column) => ColumnOrderings(column), ); - $$AccountsTableAnnotationComposer get accountId { - final $$AccountsTableAnnotationComposer composer = $composerBuilder( + $$CalendarEventsTableOrderingComposer get calendarEventId { + final $$CalendarEventsTableOrderingComposer composer = $composerBuilder( composer: this, - getCurrentColumn: (t) => t.accountId, - referencedTable: $db.accounts, + getCurrentColumn: (t) => t.calendarEventId, + referencedTable: $db.calendarEvents, getReferencedColumn: (t) => t.id, builder: ( joinBuilder, { $addJoinBuilderToRootComposer, $removeJoinBuilderFromRootComposer, - }) => $$AccountsTableAnnotationComposer( + }) => $$CalendarEventsTableOrderingComposer( $db: $db, - $table: $db.accounts, + $table: $db.calendarEvents, $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, joinBuilder: joinBuilder, $removeJoinBuilderFromRootComposer: @@ -41563,21 +43254,56 @@ class $$WebCalSubscriptionsTableAnnotationComposer ); return composer; } +} - $$CalendarSourcesTableAnnotationComposer get calendarSourceId { - final $$CalendarSourcesTableAnnotationComposer composer = $composerBuilder( +class $$CalendarEventRemindersTableAnnotationComposer + extends Composer<_$AppDatabase, $CalendarEventRemindersTable> { + $$CalendarEventRemindersTableAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + GeneratedColumn get id => + $composableBuilder(column: $table.id, builder: (column) => column); + + GeneratedColumn get provider => + $composableBuilder(column: $table.provider, builder: (column) => column); + + GeneratedColumn get method => + $composableBuilder(column: $table.method, builder: (column) => column); + + GeneratedColumn get minutesBefore => $composableBuilder( + column: $table.minutesBefore, + builder: (column) => column, + ); + + GeneratedColumn get absoluteTime => $composableBuilder( + column: $table.absoluteTime, + builder: (column) => column, + ); + + GeneratedColumn get enabled => + $composableBuilder(column: $table.enabled, builder: (column) => column); + + GeneratedColumn get rawJson => + $composableBuilder(column: $table.rawJson, builder: (column) => column); + + $$CalendarEventsTableAnnotationComposer get calendarEventId { + final $$CalendarEventsTableAnnotationComposer composer = $composerBuilder( composer: this, - getCurrentColumn: (t) => t.calendarSourceId, - referencedTable: $db.calendarSources, + getCurrentColumn: (t) => t.calendarEventId, + referencedTable: $db.calendarEvents, getReferencedColumn: (t) => t.id, builder: ( joinBuilder, { $addJoinBuilderToRootComposer, $removeJoinBuilderFromRootComposer, - }) => $$CalendarSourcesTableAnnotationComposer( + }) => $$CalendarEventsTableAnnotationComposer( $db: $db, - $table: $db.calendarSources, + $table: $db.calendarEvents, $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, joinBuilder: joinBuilder, $removeJoinBuilderFromRootComposer: @@ -41588,307 +43314,255 @@ class $$WebCalSubscriptionsTableAnnotationComposer } } -class $$WebCalSubscriptionsTableTableManager +class $$CalendarEventRemindersTableTableManager extends RootTableManager< _$AppDatabase, - $WebCalSubscriptionsTable, - WebCalSubscription, - $$WebCalSubscriptionsTableFilterComposer, - $$WebCalSubscriptionsTableOrderingComposer, - $$WebCalSubscriptionsTableAnnotationComposer, - $$WebCalSubscriptionsTableCreateCompanionBuilder, - $$WebCalSubscriptionsTableUpdateCompanionBuilder, - (WebCalSubscription, $$WebCalSubscriptionsTableReferences), - WebCalSubscription, - PrefetchHooks Function({bool accountId, bool calendarSourceId}) + $CalendarEventRemindersTable, + CalendarEventReminder, + $$CalendarEventRemindersTableFilterComposer, + $$CalendarEventRemindersTableOrderingComposer, + $$CalendarEventRemindersTableAnnotationComposer, + $$CalendarEventRemindersTableCreateCompanionBuilder, + $$CalendarEventRemindersTableUpdateCompanionBuilder, + (CalendarEventReminder, $$CalendarEventRemindersTableReferences), + CalendarEventReminder, + PrefetchHooks Function({bool calendarEventId}) > { - $$WebCalSubscriptionsTableTableManager( + $$CalendarEventRemindersTableTableManager( _$AppDatabase db, - $WebCalSubscriptionsTable table, + $CalendarEventRemindersTable table, ) : super( TableManagerState( db: db, - table: table, - createFilteringComposer: () => - $$WebCalSubscriptionsTableFilterComposer($db: db, $table: table), - createOrderingComposer: () => - $$WebCalSubscriptionsTableOrderingComposer( - $db: db, - $table: table, - ), - createComputedFieldComposer: () => - $$WebCalSubscriptionsTableAnnotationComposer( - $db: db, - $table: table, - ), - updateCompanionCallback: - ({ - Value id = const Value.absent(), - Value accountId = const Value.absent(), - Value calendarSourceId = const Value.absent(), - Value feedFingerprint = const Value.absent(), - Value safeOrigin = const Value.absent(), - Value validatorTargetFingerprint = - const Value.absent(), - Value etag = const Value.absent(), - Value lastModified = const Value.absent(), - Value contentType = const Value.absent(), - Value snapshotIcsBody = const Value.absent(), - Value rawBodyHash = const Value.absent(), - Value semanticHash = const Value.absent(), - Value refreshMode = const Value.absent(), - Value serverRefreshIntervalSeconds = const Value.absent(), - Value nextRefreshAtUtc = const Value.absent(), - Value lastCheckedAtUtc = const Value.absent(), - Value lastSuccessfulSyncAtUtc = const Value.absent(), - Value lastChangedAtUtc = const Value.absent(), - Value consecutiveFailureCount = const Value.absent(), - Value lastFailureCode = const Value.absent(), - Value lastFailureHttpStatus = const Value.absent(), - Value generation = const Value.absent(), - Value parserVersion = const Value.absent(), - Value projectionVersion = const Value.absent(), - Value projectionRangeStartUtc = const Value.absent(), - Value projectionRangeEndUtc = const Value.absent(), - Value createdAtUtc = const Value.absent(), - Value updatedAtUtc = const Value.absent(), + table: table, + createFilteringComposer: () => + $$CalendarEventRemindersTableFilterComposer( + $db: db, + $table: table, + ), + createOrderingComposer: () => + $$CalendarEventRemindersTableOrderingComposer( + $db: db, + $table: table, + ), + createComputedFieldComposer: () => + $$CalendarEventRemindersTableAnnotationComposer( + $db: db, + $table: table, + ), + updateCompanionCallback: + ({ + Value id = const Value.absent(), + Value calendarEventId = const Value.absent(), + Value provider = const Value.absent(), + Value method = const Value.absent(), + Value minutesBefore = const Value.absent(), + Value absoluteTime = const Value.absent(), + Value enabled = const Value.absent(), + Value rawJson = const Value.absent(), Value rowid = const Value.absent(), - }) => WebCalSubscriptionsCompanion( + }) => CalendarEventRemindersCompanion( id: id, - accountId: accountId, - calendarSourceId: calendarSourceId, - feedFingerprint: feedFingerprint, - safeOrigin: safeOrigin, - validatorTargetFingerprint: validatorTargetFingerprint, - etag: etag, - lastModified: lastModified, - contentType: contentType, - snapshotIcsBody: snapshotIcsBody, - rawBodyHash: rawBodyHash, - semanticHash: semanticHash, - refreshMode: refreshMode, - serverRefreshIntervalSeconds: serverRefreshIntervalSeconds, - nextRefreshAtUtc: nextRefreshAtUtc, - lastCheckedAtUtc: lastCheckedAtUtc, - lastSuccessfulSyncAtUtc: lastSuccessfulSyncAtUtc, - lastChangedAtUtc: lastChangedAtUtc, - consecutiveFailureCount: consecutiveFailureCount, - lastFailureCode: lastFailureCode, - lastFailureHttpStatus: lastFailureHttpStatus, - generation: generation, - parserVersion: parserVersion, - projectionVersion: projectionVersion, - projectionRangeStartUtc: projectionRangeStartUtc, - projectionRangeEndUtc: projectionRangeEndUtc, - createdAtUtc: createdAtUtc, - updatedAtUtc: updatedAtUtc, + calendarEventId: calendarEventId, + provider: provider, + method: method, + minutesBefore: minutesBefore, + absoluteTime: absoluteTime, + enabled: enabled, + rawJson: rawJson, rowid: rowid, ), createCompanionCallback: ({ required String id, - required String accountId, - required String calendarSourceId, - required String feedFingerprint, - required String safeOrigin, - Value validatorTargetFingerprint = - const Value.absent(), - Value etag = const Value.absent(), - Value lastModified = const Value.absent(), - Value contentType = const Value.absent(), - required String snapshotIcsBody, - required String rawBodyHash, - required String semanticHash, - required String refreshMode, - Value serverRefreshIntervalSeconds = const Value.absent(), - required String nextRefreshAtUtc, - Value lastCheckedAtUtc = const Value.absent(), - Value lastSuccessfulSyncAtUtc = const Value.absent(), - Value lastChangedAtUtc = const Value.absent(), - Value consecutiveFailureCount = const Value.absent(), - Value lastFailureCode = const Value.absent(), - Value lastFailureHttpStatus = const Value.absent(), - Value generation = const Value.absent(), - Value parserVersion = const Value.absent(), - Value projectionVersion = const Value.absent(), - Value projectionRangeStartUtc = const Value.absent(), - Value projectionRangeEndUtc = const Value.absent(), - required String createdAtUtc, - required String updatedAtUtc, + required String calendarEventId, + required String provider, + Value method = const Value.absent(), + Value minutesBefore = const Value.absent(), + Value absoluteTime = const Value.absent(), + Value enabled = const Value.absent(), + Value rawJson = const Value.absent(), Value rowid = const Value.absent(), - }) => WebCalSubscriptionsCompanion.insert( + }) => CalendarEventRemindersCompanion.insert( id: id, - accountId: accountId, - calendarSourceId: calendarSourceId, - feedFingerprint: feedFingerprint, - safeOrigin: safeOrigin, - validatorTargetFingerprint: validatorTargetFingerprint, - etag: etag, - lastModified: lastModified, - contentType: contentType, - snapshotIcsBody: snapshotIcsBody, - rawBodyHash: rawBodyHash, - semanticHash: semanticHash, - refreshMode: refreshMode, - serverRefreshIntervalSeconds: serverRefreshIntervalSeconds, - nextRefreshAtUtc: nextRefreshAtUtc, - lastCheckedAtUtc: lastCheckedAtUtc, - lastSuccessfulSyncAtUtc: lastSuccessfulSyncAtUtc, - lastChangedAtUtc: lastChangedAtUtc, - consecutiveFailureCount: consecutiveFailureCount, - lastFailureCode: lastFailureCode, - lastFailureHttpStatus: lastFailureHttpStatus, - generation: generation, - parserVersion: parserVersion, - projectionVersion: projectionVersion, - projectionRangeStartUtc: projectionRangeStartUtc, - projectionRangeEndUtc: projectionRangeEndUtc, - createdAtUtc: createdAtUtc, - updatedAtUtc: updatedAtUtc, + calendarEventId: calendarEventId, + provider: provider, + method: method, + minutesBefore: minutesBefore, + absoluteTime: absoluteTime, + enabled: enabled, + rawJson: rawJson, rowid: rowid, ), withReferenceMapper: (p0) => p0 .map( (e) => ( - e.readTable(table), - $$WebCalSubscriptionsTableReferences(db, table, e), + e.readTable< + $CalendarEventRemindersTable, + CalendarEventReminder + >(table), + $$CalendarEventRemindersTableReferences(db, table, e), ), ) .toList(), - prefetchHooksCallback: - ({accountId = false, calendarSourceId = false}) { - return PrefetchHooks( - db: db, - explicitlyWatchedTables: [], - addJoins: - < - T extends TableManagerState< - dynamic, - dynamic, - dynamic, - dynamic, - dynamic, - dynamic, - dynamic, - dynamic, - dynamic, - dynamic, - dynamic - > - >(state) { - if (accountId) { - state = - state.withJoin( - currentTable: table, - currentColumn: table.accountId, - referencedTable: - $$WebCalSubscriptionsTableReferences - ._accountIdTable(db), - referencedColumn: - $$WebCalSubscriptionsTableReferences - ._accountIdTable(db) - .id, - ) - as T; - } - if (calendarSourceId) { - state = - state.withJoin( - currentTable: table, - currentColumn: table.calendarSourceId, - referencedTable: - $$WebCalSubscriptionsTableReferences - ._calendarSourceIdTable(db), - referencedColumn: - $$WebCalSubscriptionsTableReferences - ._calendarSourceIdTable(db) - .id, - ) - as T; - } + prefetchHooksCallback: ({calendarEventId = false}) { + return PrefetchHooks( + db: db, + explicitlyWatchedTables: [], + addJoins: + < + T extends TableManagerState< + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic + > + >(state) { + if (calendarEventId) { + state = + state.withJoin( + currentTable: table, + currentColumn: table.calendarEventId, + referencedTable: + $$CalendarEventRemindersTableReferences + ._calendarEventIdTable(db), + referencedColumn: + $$CalendarEventRemindersTableReferences + ._calendarEventIdTable(db) + .id, + ) + as T; + } - return state; - }, - getPrefetchedDataCallback: (items) async { - return []; + return state; }, - ); + getPrefetchedDataCallback: (items) async { + return []; }, + ); + }, ), ); } -typedef $$WebCalSubscriptionsTableProcessedTableManager = +typedef $$CalendarEventRemindersTableProcessedTableManager = ProcessedTableManager< _$AppDatabase, - $WebCalSubscriptionsTable, - WebCalSubscription, - $$WebCalSubscriptionsTableFilterComposer, - $$WebCalSubscriptionsTableOrderingComposer, - $$WebCalSubscriptionsTableAnnotationComposer, - $$WebCalSubscriptionsTableCreateCompanionBuilder, - $$WebCalSubscriptionsTableUpdateCompanionBuilder, - (WebCalSubscription, $$WebCalSubscriptionsTableReferences), - WebCalSubscription, - PrefetchHooks Function({bool accountId, bool calendarSourceId}) + $CalendarEventRemindersTable, + CalendarEventReminder, + $$CalendarEventRemindersTableFilterComposer, + $$CalendarEventRemindersTableOrderingComposer, + $$CalendarEventRemindersTableAnnotationComposer, + $$CalendarEventRemindersTableCreateCompanionBuilder, + $$CalendarEventRemindersTableUpdateCompanionBuilder, + (CalendarEventReminder, $$CalendarEventRemindersTableReferences), + CalendarEventReminder, + PrefetchHooks Function({bool calendarEventId}) >; -typedef $$CalendarEventAttendeesTableCreateCompanionBuilder = - CalendarEventAttendeesCompanion Function({ +typedef $$SyncCursorsTableCreateCompanionBuilder = + SyncCursorsCompanion Function({ required String id, - required String calendarEventId, - required String email, - Value displayName, - Value responseStatus, - Value optional, - Value organizer, - Value self, - Value rawJson, + required String accountId, + Value projectionSourceId, + required String provider, + required String transport, + required String syncScopeKind, + Value davCollectionId, + required String cursorKind, + required String cursorValue, + Value rangeStart, + Value rangeEnd, + Value baselineGeneration, + Value inProgressCursor, + Value inProgressGeneration, + Value lastCompleteSyncAt, + Value lastFailureCode, + Value stateSchemaVersion, + Value stateJson, Value rowid, }); -typedef $$CalendarEventAttendeesTableUpdateCompanionBuilder = - CalendarEventAttendeesCompanion Function({ +typedef $$SyncCursorsTableUpdateCompanionBuilder = + SyncCursorsCompanion Function({ Value id, - Value calendarEventId, - Value email, - Value displayName, - Value responseStatus, - Value optional, - Value organizer, - Value self, - Value rawJson, + Value accountId, + Value projectionSourceId, + Value provider, + Value transport, + Value syncScopeKind, + Value davCollectionId, + Value cursorKind, + Value cursorValue, + Value rangeStart, + Value rangeEnd, + Value baselineGeneration, + Value inProgressCursor, + Value inProgressGeneration, + Value lastCompleteSyncAt, + Value lastFailureCode, + Value stateSchemaVersion, + Value stateJson, Value rowid, }); -final class $$CalendarEventAttendeesTableReferences - extends - BaseReferences< - _$AppDatabase, - $CalendarEventAttendeesTable, - CalendarEventAttendee - > { - $$CalendarEventAttendeesTableReferences( - super.$_db, - super.$_table, - super.$_typedResult, - ); +final class $$SyncCursorsTableReferences + extends BaseReferences<_$AppDatabase, $SyncCursorsTable, SyncCursor> { + $$SyncCursorsTableReferences(super.$_db, super.$_table, super.$_typedResult); - static $CalendarEventsTable _calendarEventIdTable(_$AppDatabase db) => - db.calendarEvents.createAlias( - $_aliasNameGenerator( - db.calendarEventAttendees.calendarEventId, - db.calendarEvents.id, - ), - ); + static $AccountsTable _accountIdTable(_$AppDatabase db) => + db.accounts.createAlias('sync_cursors__account_id__accounts__id'); - $$CalendarEventsTableProcessedTableManager get calendarEventId { - final $_column = $_itemColumn('calendar_event_id')!; + $$AccountsTableProcessedTableManager get accountId { + final $_column = $_itemColumn('account_id')!; - final manager = $$CalendarEventsTableTableManager( + final manager = $$AccountsTableTableManager( $_db, - $_db.calendarEvents, + $_db.accounts, ).filter((f) => f.id.sqlEquals($_column)); - final item = $_typedResult.readTableOrNull(_calendarEventIdTable($_db)); + final item = $_typedResult.readTableOrNull(_accountIdTable($_db)); + if (item == null) return manager; + return ProcessedTableManager( + manager.$state.copyWith(prefetchedData: [item]), + ); + } + + static $CalendarSourcesTable _projectionSourceIdTable(_$AppDatabase db) => db + .calendarSources + .createAlias('sync_cursors__projection_source_id__calendar_sources__id'); + + $$CalendarSourcesTableProcessedTableManager? get projectionSourceId { + final $_column = $_itemColumn('projection_source_id'); + if ($_column == null) return null; + final manager = $$CalendarSourcesTableTableManager( + $_db, + $_db.calendarSources, + ).filter((f) => f.id.sqlEquals($_column)); + final item = $_typedResult.readTableOrNull(_projectionSourceIdTable($_db)); + if (item == null) return manager; + return ProcessedTableManager( + manager.$state.copyWith(prefetchedData: [item]), + ); + } + + static $DavCollectionsTable _davCollectionIdTable(_$AppDatabase db) => db + .davCollections + .createAlias('sync_cursors__dav_collection_id__dav_collections__id'); + + $$DavCollectionsTableProcessedTableManager? get davCollectionId { + final $_column = $_itemColumn('dav_collection_id'); + if ($_column == null) return null; + final manager = $$DavCollectionsTableTableManager( + $_db, + $_db.davCollections, + ).filter((f) => f.id.sqlEquals($_column)); + final item = $_typedResult.readTableOrNull(_davCollectionIdTable($_db)); if (item == null) return manager; return ProcessedTableManager( manager.$state.copyWith(prefetchedData: [item]), @@ -41896,69 +43570,304 @@ final class $$CalendarEventAttendeesTableReferences } } -class $$CalendarEventAttendeesTableFilterComposer - extends Composer<_$AppDatabase, $CalendarEventAttendeesTable> { - $$CalendarEventAttendeesTableFilterComposer({ +class $$SyncCursorsTableFilterComposer + extends Composer<_$AppDatabase, $SyncCursorsTable> { + $$SyncCursorsTableFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnFilters get id => $composableBuilder( + column: $table.id, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get provider => $composableBuilder( + column: $table.provider, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get transport => $composableBuilder( + column: $table.transport, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get syncScopeKind => $composableBuilder( + column: $table.syncScopeKind, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get cursorKind => $composableBuilder( + column: $table.cursorKind, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get cursorValue => $composableBuilder( + column: $table.cursorValue, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get rangeStart => $composableBuilder( + column: $table.rangeStart, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get rangeEnd => $composableBuilder( + column: $table.rangeEnd, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get baselineGeneration => $composableBuilder( + column: $table.baselineGeneration, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get inProgressCursor => $composableBuilder( + column: $table.inProgressCursor, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get inProgressGeneration => $composableBuilder( + column: $table.inProgressGeneration, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get lastCompleteSyncAt => $composableBuilder( + column: $table.lastCompleteSyncAt, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get lastFailureCode => $composableBuilder( + column: $table.lastFailureCode, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get stateSchemaVersion => $composableBuilder( + column: $table.stateSchemaVersion, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get stateJson => $composableBuilder( + column: $table.stateJson, + builder: (column) => ColumnFilters(column), + ); + + $$AccountsTableFilterComposer get accountId { + final $$AccountsTableFilterComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.accountId, + referencedTable: $db.accounts, + getReferencedColumn: (t) => t.id, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$AccountsTableFilterComposer( + $db: $db, + $table: $db.accounts, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return composer; + } + + $$CalendarSourcesTableFilterComposer get projectionSourceId { + final $$CalendarSourcesTableFilterComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.projectionSourceId, + referencedTable: $db.calendarSources, + getReferencedColumn: (t) => t.id, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$CalendarSourcesTableFilterComposer( + $db: $db, + $table: $db.calendarSources, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return composer; + } + + $$DavCollectionsTableFilterComposer get davCollectionId { + final $$DavCollectionsTableFilterComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.davCollectionId, + referencedTable: $db.davCollections, + getReferencedColumn: (t) => t.id, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$DavCollectionsTableFilterComposer( + $db: $db, + $table: $db.davCollections, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return composer; + } +} + +class $$SyncCursorsTableOrderingComposer + extends Composer<_$AppDatabase, $SyncCursorsTable> { + $$SyncCursorsTableOrderingComposer({ required super.$db, required super.$table, super.joinBuilder, super.$addJoinBuilderToRootComposer, super.$removeJoinBuilderFromRootComposer, }); - ColumnFilters get id => $composableBuilder( + ColumnOrderings get id => $composableBuilder( column: $table.id, - builder: (column) => ColumnFilters(column), + builder: (column) => ColumnOrderings(column), ); - ColumnFilters get email => $composableBuilder( - column: $table.email, - builder: (column) => ColumnFilters(column), + ColumnOrderings get provider => $composableBuilder( + column: $table.provider, + builder: (column) => ColumnOrderings(column), ); - ColumnFilters get displayName => $composableBuilder( - column: $table.displayName, - builder: (column) => ColumnFilters(column), + ColumnOrderings get transport => $composableBuilder( + column: $table.transport, + builder: (column) => ColumnOrderings(column), ); - ColumnFilters get responseStatus => $composableBuilder( - column: $table.responseStatus, - builder: (column) => ColumnFilters(column), + ColumnOrderings get syncScopeKind => $composableBuilder( + column: $table.syncScopeKind, + builder: (column) => ColumnOrderings(column), ); - ColumnFilters get optional => $composableBuilder( - column: $table.optional, - builder: (column) => ColumnFilters(column), + ColumnOrderings get cursorKind => $composableBuilder( + column: $table.cursorKind, + builder: (column) => ColumnOrderings(column), ); - ColumnFilters get organizer => $composableBuilder( - column: $table.organizer, - builder: (column) => ColumnFilters(column), + ColumnOrderings get cursorValue => $composableBuilder( + column: $table.cursorValue, + builder: (column) => ColumnOrderings(column), ); - ColumnFilters get self => $composableBuilder( - column: $table.self, - builder: (column) => ColumnFilters(column), + ColumnOrderings get rangeStart => $composableBuilder( + column: $table.rangeStart, + builder: (column) => ColumnOrderings(column), ); - ColumnFilters get rawJson => $composableBuilder( - column: $table.rawJson, - builder: (column) => ColumnFilters(column), + ColumnOrderings get rangeEnd => $composableBuilder( + column: $table.rangeEnd, + builder: (column) => ColumnOrderings(column), ); - $$CalendarEventsTableFilterComposer get calendarEventId { - final $$CalendarEventsTableFilterComposer composer = $composerBuilder( + ColumnOrderings get baselineGeneration => $composableBuilder( + column: $table.baselineGeneration, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get inProgressCursor => $composableBuilder( + column: $table.inProgressCursor, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get inProgressGeneration => $composableBuilder( + column: $table.inProgressGeneration, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get lastCompleteSyncAt => $composableBuilder( + column: $table.lastCompleteSyncAt, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get lastFailureCode => $composableBuilder( + column: $table.lastFailureCode, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get stateSchemaVersion => $composableBuilder( + column: $table.stateSchemaVersion, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get stateJson => $composableBuilder( + column: $table.stateJson, + builder: (column) => ColumnOrderings(column), + ); + + $$AccountsTableOrderingComposer get accountId { + final $$AccountsTableOrderingComposer composer = $composerBuilder( composer: this, - getCurrentColumn: (t) => t.calendarEventId, - referencedTable: $db.calendarEvents, + getCurrentColumn: (t) => t.accountId, + referencedTable: $db.accounts, getReferencedColumn: (t) => t.id, builder: ( joinBuilder, { $addJoinBuilderToRootComposer, $removeJoinBuilderFromRootComposer, - }) => $$CalendarEventsTableFilterComposer( + }) => $$AccountsTableOrderingComposer( $db: $db, - $table: $db.calendarEvents, + $table: $db.accounts, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return composer; + } + + $$CalendarSourcesTableOrderingComposer get projectionSourceId { + final $$CalendarSourcesTableOrderingComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.projectionSourceId, + referencedTable: $db.calendarSources, + getReferencedColumn: (t) => t.id, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$CalendarSourcesTableOrderingComposer( + $db: $db, + $table: $db.calendarSources, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return composer; + } + + $$DavCollectionsTableOrderingComposer get davCollectionId { + final $$DavCollectionsTableOrderingComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.davCollectionId, + referencedTable: $db.davCollections, + getReferencedColumn: (t) => t.id, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$DavCollectionsTableOrderingComposer( + $db: $db, + $table: $db.davCollections, $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, joinBuilder: joinBuilder, $removeJoinBuilderFromRootComposer: @@ -41969,69 +43878,117 @@ class $$CalendarEventAttendeesTableFilterComposer } } -class $$CalendarEventAttendeesTableOrderingComposer - extends Composer<_$AppDatabase, $CalendarEventAttendeesTable> { - $$CalendarEventAttendeesTableOrderingComposer({ +class $$SyncCursorsTableAnnotationComposer + extends Composer<_$AppDatabase, $SyncCursorsTable> { + $$SyncCursorsTableAnnotationComposer({ required super.$db, required super.$table, super.joinBuilder, super.$addJoinBuilderToRootComposer, super.$removeJoinBuilderFromRootComposer, }); - ColumnOrderings get id => $composableBuilder( - column: $table.id, - builder: (column) => ColumnOrderings(column), + GeneratedColumn get id => + $composableBuilder(column: $table.id, builder: (column) => column); + + GeneratedColumn get provider => + $composableBuilder(column: $table.provider, builder: (column) => column); + + GeneratedColumn get transport => + $composableBuilder(column: $table.transport, builder: (column) => column); + + GeneratedColumn get syncScopeKind => $composableBuilder( + column: $table.syncScopeKind, + builder: (column) => column, ); - ColumnOrderings get email => $composableBuilder( - column: $table.email, - builder: (column) => ColumnOrderings(column), + GeneratedColumn get cursorKind => $composableBuilder( + column: $table.cursorKind, + builder: (column) => column, ); - ColumnOrderings get displayName => $composableBuilder( - column: $table.displayName, - builder: (column) => ColumnOrderings(column), + GeneratedColumn get cursorValue => $composableBuilder( + column: $table.cursorValue, + builder: (column) => column, ); - ColumnOrderings get responseStatus => $composableBuilder( - column: $table.responseStatus, - builder: (column) => ColumnOrderings(column), + GeneratedColumn get rangeStart => $composableBuilder( + column: $table.rangeStart, + builder: (column) => column, ); - ColumnOrderings get optional => $composableBuilder( - column: $table.optional, - builder: (column) => ColumnOrderings(column), + GeneratedColumn get rangeEnd => + $composableBuilder(column: $table.rangeEnd, builder: (column) => column); + + GeneratedColumn get baselineGeneration => $composableBuilder( + column: $table.baselineGeneration, + builder: (column) => column, ); - ColumnOrderings get organizer => $composableBuilder( - column: $table.organizer, - builder: (column) => ColumnOrderings(column), + GeneratedColumn get inProgressCursor => $composableBuilder( + column: $table.inProgressCursor, + builder: (column) => column, ); - ColumnOrderings get self => $composableBuilder( - column: $table.self, - builder: (column) => ColumnOrderings(column), + GeneratedColumn get inProgressGeneration => $composableBuilder( + column: $table.inProgressGeneration, + builder: (column) => column, ); - ColumnOrderings get rawJson => $composableBuilder( - column: $table.rawJson, - builder: (column) => ColumnOrderings(column), + GeneratedColumn get lastCompleteSyncAt => $composableBuilder( + column: $table.lastCompleteSyncAt, + builder: (column) => column, ); - $$CalendarEventsTableOrderingComposer get calendarEventId { - final $$CalendarEventsTableOrderingComposer composer = $composerBuilder( + GeneratedColumn get lastFailureCode => $composableBuilder( + column: $table.lastFailureCode, + builder: (column) => column, + ); + + GeneratedColumn get stateSchemaVersion => $composableBuilder( + column: $table.stateSchemaVersion, + builder: (column) => column, + ); + + GeneratedColumn get stateJson => + $composableBuilder(column: $table.stateJson, builder: (column) => column); + + $$AccountsTableAnnotationComposer get accountId { + final $$AccountsTableAnnotationComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.accountId, + referencedTable: $db.accounts, + getReferencedColumn: (t) => t.id, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$AccountsTableAnnotationComposer( + $db: $db, + $table: $db.accounts, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return composer; + } + + $$CalendarSourcesTableAnnotationComposer get projectionSourceId { + final $$CalendarSourcesTableAnnotationComposer composer = $composerBuilder( composer: this, - getCurrentColumn: (t) => t.calendarEventId, - referencedTable: $db.calendarEvents, + getCurrentColumn: (t) => t.projectionSourceId, + referencedTable: $db.calendarSources, getReferencedColumn: (t) => t.id, builder: ( joinBuilder, { $addJoinBuilderToRootComposer, $removeJoinBuilderFromRootComposer, - }) => $$CalendarEventsTableOrderingComposer( + }) => $$CalendarSourcesTableAnnotationComposer( $db: $db, - $table: $db.calendarEvents, + $table: $db.calendarSources, $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, joinBuilder: joinBuilder, $removeJoinBuilderFromRootComposer: @@ -42040,59 +43997,21 @@ class $$CalendarEventAttendeesTableOrderingComposer ); return composer; } -} - -class $$CalendarEventAttendeesTableAnnotationComposer - extends Composer<_$AppDatabase, $CalendarEventAttendeesTable> { - $$CalendarEventAttendeesTableAnnotationComposer({ - required super.$db, - required super.$table, - super.joinBuilder, - super.$addJoinBuilderToRootComposer, - super.$removeJoinBuilderFromRootComposer, - }); - GeneratedColumn get id => - $composableBuilder(column: $table.id, builder: (column) => column); - - GeneratedColumn get email => - $composableBuilder(column: $table.email, builder: (column) => column); - GeneratedColumn get displayName => $composableBuilder( - column: $table.displayName, - builder: (column) => column, - ); - - GeneratedColumn get responseStatus => $composableBuilder( - column: $table.responseStatus, - builder: (column) => column, - ); - - GeneratedColumn get optional => - $composableBuilder(column: $table.optional, builder: (column) => column); - - GeneratedColumn get organizer => - $composableBuilder(column: $table.organizer, builder: (column) => column); - - GeneratedColumn get self => - $composableBuilder(column: $table.self, builder: (column) => column); - - GeneratedColumn get rawJson => - $composableBuilder(column: $table.rawJson, builder: (column) => column); - - $$CalendarEventsTableAnnotationComposer get calendarEventId { - final $$CalendarEventsTableAnnotationComposer composer = $composerBuilder( + $$DavCollectionsTableAnnotationComposer get davCollectionId { + final $$DavCollectionsTableAnnotationComposer composer = $composerBuilder( composer: this, - getCurrentColumn: (t) => t.calendarEventId, - referencedTable: $db.calendarEvents, + getCurrentColumn: (t) => t.davCollectionId, + referencedTable: $db.davCollections, getReferencedColumn: (t) => t.id, builder: ( joinBuilder, { $addJoinBuilderToRootComposer, $removeJoinBuilderFromRootComposer, - }) => $$CalendarEventsTableAnnotationComposer( + }) => $$DavCollectionsTableAnnotationComposer( $db: $db, - $table: $db.calendarEvents, + $table: $db.davCollections, $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, joinBuilder: joinBuilder, $removeJoinBuilderFromRootComposer: @@ -42103,257 +44022,280 @@ class $$CalendarEventAttendeesTableAnnotationComposer } } -class $$CalendarEventAttendeesTableTableManager +class $$SyncCursorsTableTableManager extends RootTableManager< _$AppDatabase, - $CalendarEventAttendeesTable, - CalendarEventAttendee, - $$CalendarEventAttendeesTableFilterComposer, - $$CalendarEventAttendeesTableOrderingComposer, - $$CalendarEventAttendeesTableAnnotationComposer, - $$CalendarEventAttendeesTableCreateCompanionBuilder, - $$CalendarEventAttendeesTableUpdateCompanionBuilder, - (CalendarEventAttendee, $$CalendarEventAttendeesTableReferences), - CalendarEventAttendee, - PrefetchHooks Function({bool calendarEventId}) + $SyncCursorsTable, + SyncCursor, + $$SyncCursorsTableFilterComposer, + $$SyncCursorsTableOrderingComposer, + $$SyncCursorsTableAnnotationComposer, + $$SyncCursorsTableCreateCompanionBuilder, + $$SyncCursorsTableUpdateCompanionBuilder, + (SyncCursor, $$SyncCursorsTableReferences), + SyncCursor, + PrefetchHooks Function({ + bool accountId, + bool projectionSourceId, + bool davCollectionId, + }) > { - $$CalendarEventAttendeesTableTableManager( - _$AppDatabase db, - $CalendarEventAttendeesTable table, - ) : super( + $$SyncCursorsTableTableManager(_$AppDatabase db, $SyncCursorsTable table) + : super( TableManagerState( db: db, table: table, createFilteringComposer: () => - $$CalendarEventAttendeesTableFilterComposer( - $db: db, - $table: table, - ), + $$SyncCursorsTableFilterComposer($db: db, $table: table), createOrderingComposer: () => - $$CalendarEventAttendeesTableOrderingComposer( - $db: db, - $table: table, - ), + $$SyncCursorsTableOrderingComposer($db: db, $table: table), createComputedFieldComposer: () => - $$CalendarEventAttendeesTableAnnotationComposer( - $db: db, - $table: table, - ), + $$SyncCursorsTableAnnotationComposer($db: db, $table: table), updateCompanionCallback: ({ Value id = const Value.absent(), - Value calendarEventId = const Value.absent(), - Value email = const Value.absent(), - Value displayName = const Value.absent(), - Value responseStatus = const Value.absent(), - Value optional = const Value.absent(), - Value organizer = const Value.absent(), - Value self = const Value.absent(), - Value rawJson = const Value.absent(), + Value accountId = const Value.absent(), + Value projectionSourceId = const Value.absent(), + Value provider = const Value.absent(), + Value transport = const Value.absent(), + Value syncScopeKind = const Value.absent(), + Value davCollectionId = const Value.absent(), + Value cursorKind = const Value.absent(), + Value cursorValue = const Value.absent(), + Value rangeStart = const Value.absent(), + Value rangeEnd = const Value.absent(), + Value baselineGeneration = const Value.absent(), + Value inProgressCursor = const Value.absent(), + Value inProgressGeneration = const Value.absent(), + Value lastCompleteSyncAt = const Value.absent(), + Value lastFailureCode = const Value.absent(), + Value stateSchemaVersion = const Value.absent(), + Value stateJson = const Value.absent(), Value rowid = const Value.absent(), - }) => CalendarEventAttendeesCompanion( + }) => SyncCursorsCompanion( id: id, - calendarEventId: calendarEventId, - email: email, - displayName: displayName, - responseStatus: responseStatus, - optional: optional, - organizer: organizer, - self: self, - rawJson: rawJson, + accountId: accountId, + projectionSourceId: projectionSourceId, + provider: provider, + transport: transport, + syncScopeKind: syncScopeKind, + davCollectionId: davCollectionId, + cursorKind: cursorKind, + cursorValue: cursorValue, + rangeStart: rangeStart, + rangeEnd: rangeEnd, + baselineGeneration: baselineGeneration, + inProgressCursor: inProgressCursor, + inProgressGeneration: inProgressGeneration, + lastCompleteSyncAt: lastCompleteSyncAt, + lastFailureCode: lastFailureCode, + stateSchemaVersion: stateSchemaVersion, + stateJson: stateJson, rowid: rowid, ), createCompanionCallback: ({ required String id, - required String calendarEventId, - required String email, - Value displayName = const Value.absent(), - Value responseStatus = const Value.absent(), - Value optional = const Value.absent(), - Value organizer = const Value.absent(), - Value self = const Value.absent(), - Value rawJson = const Value.absent(), + required String accountId, + Value projectionSourceId = const Value.absent(), + required String provider, + required String transport, + required String syncScopeKind, + Value davCollectionId = const Value.absent(), + required String cursorKind, + required String cursorValue, + Value rangeStart = const Value.absent(), + Value rangeEnd = const Value.absent(), + Value baselineGeneration = const Value.absent(), + Value inProgressCursor = const Value.absent(), + Value inProgressGeneration = const Value.absent(), + Value lastCompleteSyncAt = const Value.absent(), + Value lastFailureCode = const Value.absent(), + Value stateSchemaVersion = const Value.absent(), + Value stateJson = const Value.absent(), Value rowid = const Value.absent(), - }) => CalendarEventAttendeesCompanion.insert( + }) => SyncCursorsCompanion.insert( id: id, - calendarEventId: calendarEventId, - email: email, - displayName: displayName, - responseStatus: responseStatus, - optional: optional, - organizer: organizer, - self: self, - rawJson: rawJson, - rowid: rowid, - ), - withReferenceMapper: (p0) => p0 - .map( - (e) => ( - e.readTable(table), - $$CalendarEventAttendeesTableReferences(db, table, e), - ), - ) - .toList(), - prefetchHooksCallback: ({calendarEventId = false}) { - return PrefetchHooks( - db: db, - explicitlyWatchedTables: [], - addJoins: - < - T extends TableManagerState< - dynamic, - dynamic, - dynamic, - dynamic, - dynamic, - dynamic, - dynamic, - dynamic, - dynamic, - dynamic, - dynamic - > - >(state) { - if (calendarEventId) { - state = - state.withJoin( - currentTable: table, - currentColumn: table.calendarEventId, - referencedTable: - $$CalendarEventAttendeesTableReferences - ._calendarEventIdTable(db), - referencedColumn: - $$CalendarEventAttendeesTableReferences - ._calendarEventIdTable(db) - .id, - ) - as T; - } + accountId: accountId, + projectionSourceId: projectionSourceId, + provider: provider, + transport: transport, + syncScopeKind: syncScopeKind, + davCollectionId: davCollectionId, + cursorKind: cursorKind, + cursorValue: cursorValue, + rangeStart: rangeStart, + rangeEnd: rangeEnd, + baselineGeneration: baselineGeneration, + inProgressCursor: inProgressCursor, + inProgressGeneration: inProgressGeneration, + lastCompleteSyncAt: lastCompleteSyncAt, + lastFailureCode: lastFailureCode, + stateSchemaVersion: stateSchemaVersion, + stateJson: stateJson, + rowid: rowid, + ), + withReferenceMapper: (p0) => p0 + .map( + (e) => ( + e.readTable<$SyncCursorsTable, SyncCursor>(table), + $$SyncCursorsTableReferences(db, table, e), + ), + ) + .toList(), + prefetchHooksCallback: + ({ + accountId = false, + projectionSourceId = false, + davCollectionId = false, + }) { + return PrefetchHooks( + db: db, + explicitlyWatchedTables: [], + addJoins: + < + T extends TableManagerState< + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic + > + >(state) { + if (accountId) { + state = + state.withJoin( + currentTable: table, + currentColumn: table.accountId, + referencedTable: + $$SyncCursorsTableReferences + ._accountIdTable(db), + referencedColumn: + $$SyncCursorsTableReferences + ._accountIdTable(db) + .id, + ) + as T; + } + if (projectionSourceId) { + state = + state.withJoin( + currentTable: table, + currentColumn: table.projectionSourceId, + referencedTable: + $$SyncCursorsTableReferences + ._projectionSourceIdTable(db), + referencedColumn: + $$SyncCursorsTableReferences + ._projectionSourceIdTable(db) + .id, + ) + as T; + } + if (davCollectionId) { + state = + state.withJoin( + currentTable: table, + currentColumn: table.davCollectionId, + referencedTable: + $$SyncCursorsTableReferences + ._davCollectionIdTable(db), + referencedColumn: + $$SyncCursorsTableReferences + ._davCollectionIdTable(db) + .id, + ) + as T; + } - return state; + return state; + }, + getPrefetchedDataCallback: (items) async { + return []; }, - getPrefetchedDataCallback: (items) async { - return []; + ); }, - ); - }, ), ); } -typedef $$CalendarEventAttendeesTableProcessedTableManager = +typedef $$SyncCursorsTableProcessedTableManager = ProcessedTableManager< _$AppDatabase, - $CalendarEventAttendeesTable, - CalendarEventAttendee, - $$CalendarEventAttendeesTableFilterComposer, - $$CalendarEventAttendeesTableOrderingComposer, - $$CalendarEventAttendeesTableAnnotationComposer, - $$CalendarEventAttendeesTableCreateCompanionBuilder, - $$CalendarEventAttendeesTableUpdateCompanionBuilder, - (CalendarEventAttendee, $$CalendarEventAttendeesTableReferences), - CalendarEventAttendee, - PrefetchHooks Function({bool calendarEventId}) + $SyncCursorsTable, + SyncCursor, + $$SyncCursorsTableFilterComposer, + $$SyncCursorsTableOrderingComposer, + $$SyncCursorsTableAnnotationComposer, + $$SyncCursorsTableCreateCompanionBuilder, + $$SyncCursorsTableUpdateCompanionBuilder, + (SyncCursor, $$SyncCursorsTableReferences), + SyncCursor, + PrefetchHooks Function({ + bool accountId, + bool projectionSourceId, + bool davCollectionId, + }) >; -typedef $$CalendarEventRemindersTableCreateCompanionBuilder = - CalendarEventRemindersCompanion Function({ - required String id, - required String calendarEventId, +typedef $$CalendarColorsTableCreateCompanionBuilder = + CalendarColorsCompanion Function({ required String provider, - Value method, - Value minutesBefore, - Value absoluteTime, - Value enabled, + required String colorType, + required String colorId, + required String background, + Value foreground, Value rawJson, Value rowid, }); -typedef $$CalendarEventRemindersTableUpdateCompanionBuilder = - CalendarEventRemindersCompanion Function({ - Value id, - Value calendarEventId, +typedef $$CalendarColorsTableUpdateCompanionBuilder = + CalendarColorsCompanion Function({ Value provider, - Value method, - Value minutesBefore, - Value absoluteTime, - Value enabled, + Value colorType, + Value colorId, + Value background, + Value foreground, Value rawJson, Value rowid, }); -final class $$CalendarEventRemindersTableReferences - extends - BaseReferences< - _$AppDatabase, - $CalendarEventRemindersTable, - CalendarEventReminder - > { - $$CalendarEventRemindersTableReferences( - super.$_db, - super.$_table, - super.$_typedResult, - ); - - static $CalendarEventsTable _calendarEventIdTable(_$AppDatabase db) => - db.calendarEvents.createAlias( - $_aliasNameGenerator( - db.calendarEventReminders.calendarEventId, - db.calendarEvents.id, - ), - ); - - $$CalendarEventsTableProcessedTableManager get calendarEventId { - final $_column = $_itemColumn('calendar_event_id')!; - - final manager = $$CalendarEventsTableTableManager( - $_db, - $_db.calendarEvents, - ).filter((f) => f.id.sqlEquals($_column)); - final item = $_typedResult.readTableOrNull(_calendarEventIdTable($_db)); - if (item == null) return manager; - return ProcessedTableManager( - manager.$state.copyWith(prefetchedData: [item]), - ); - } -} - -class $$CalendarEventRemindersTableFilterComposer - extends Composer<_$AppDatabase, $CalendarEventRemindersTable> { - $$CalendarEventRemindersTableFilterComposer({ +class $$CalendarColorsTableFilterComposer + extends Composer<_$AppDatabase, $CalendarColorsTable> { + $$CalendarColorsTableFilterComposer({ required super.$db, required super.$table, super.joinBuilder, super.$addJoinBuilderToRootComposer, super.$removeJoinBuilderFromRootComposer, }); - ColumnFilters get id => $composableBuilder( - column: $table.id, - builder: (column) => ColumnFilters(column), - ); - ColumnFilters get provider => $composableBuilder( column: $table.provider, builder: (column) => ColumnFilters(column), ); - ColumnFilters get method => $composableBuilder( - column: $table.method, + ColumnFilters get colorType => $composableBuilder( + column: $table.colorType, builder: (column) => ColumnFilters(column), ); - ColumnFilters get minutesBefore => $composableBuilder( - column: $table.minutesBefore, + ColumnFilters get colorId => $composableBuilder( + column: $table.colorId, builder: (column) => ColumnFilters(column), ); - ColumnFilters get absoluteTime => $composableBuilder( - column: $table.absoluteTime, + ColumnFilters get background => $composableBuilder( + column: $table.background, builder: (column) => ColumnFilters(column), ); - ColumnFilters get enabled => $composableBuilder( - column: $table.enabled, + ColumnFilters get foreground => $composableBuilder( + column: $table.foreground, builder: (column) => ColumnFilters(column), ); @@ -42361,67 +44303,39 @@ class $$CalendarEventRemindersTableFilterComposer column: $table.rawJson, builder: (column) => ColumnFilters(column), ); - - $$CalendarEventsTableFilterComposer get calendarEventId { - final $$CalendarEventsTableFilterComposer composer = $composerBuilder( - composer: this, - getCurrentColumn: (t) => t.calendarEventId, - referencedTable: $db.calendarEvents, - getReferencedColumn: (t) => t.id, - builder: - ( - joinBuilder, { - $addJoinBuilderToRootComposer, - $removeJoinBuilderFromRootComposer, - }) => $$CalendarEventsTableFilterComposer( - $db: $db, - $table: $db.calendarEvents, - $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, - joinBuilder: joinBuilder, - $removeJoinBuilderFromRootComposer: - $removeJoinBuilderFromRootComposer, - ), - ); - return composer; - } } -class $$CalendarEventRemindersTableOrderingComposer - extends Composer<_$AppDatabase, $CalendarEventRemindersTable> { - $$CalendarEventRemindersTableOrderingComposer({ +class $$CalendarColorsTableOrderingComposer + extends Composer<_$AppDatabase, $CalendarColorsTable> { + $$CalendarColorsTableOrderingComposer({ required super.$db, required super.$table, super.joinBuilder, super.$addJoinBuilderToRootComposer, super.$removeJoinBuilderFromRootComposer, }); - ColumnOrderings get id => $composableBuilder( - column: $table.id, - builder: (column) => ColumnOrderings(column), - ); - ColumnOrderings get provider => $composableBuilder( column: $table.provider, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get method => $composableBuilder( - column: $table.method, + ColumnOrderings get colorType => $composableBuilder( + column: $table.colorType, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get minutesBefore => $composableBuilder( - column: $table.minutesBefore, + ColumnOrderings get colorId => $composableBuilder( + column: $table.colorId, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get absoluteTime => $composableBuilder( - column: $table.absoluteTime, + ColumnOrderings get background => $composableBuilder( + column: $table.background, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get enabled => $composableBuilder( - column: $table.enabled, + ColumnOrderings get foreground => $composableBuilder( + column: $table.foreground, builder: (column) => ColumnOrderings(column), ); @@ -42429,292 +44343,179 @@ class $$CalendarEventRemindersTableOrderingComposer column: $table.rawJson, builder: (column) => ColumnOrderings(column), ); - - $$CalendarEventsTableOrderingComposer get calendarEventId { - final $$CalendarEventsTableOrderingComposer composer = $composerBuilder( - composer: this, - getCurrentColumn: (t) => t.calendarEventId, - referencedTable: $db.calendarEvents, - getReferencedColumn: (t) => t.id, - builder: - ( - joinBuilder, { - $addJoinBuilderToRootComposer, - $removeJoinBuilderFromRootComposer, - }) => $$CalendarEventsTableOrderingComposer( - $db: $db, - $table: $db.calendarEvents, - $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, - joinBuilder: joinBuilder, - $removeJoinBuilderFromRootComposer: - $removeJoinBuilderFromRootComposer, - ), - ); - return composer; - } } -class $$CalendarEventRemindersTableAnnotationComposer - extends Composer<_$AppDatabase, $CalendarEventRemindersTable> { - $$CalendarEventRemindersTableAnnotationComposer({ +class $$CalendarColorsTableAnnotationComposer + extends Composer<_$AppDatabase, $CalendarColorsTable> { + $$CalendarColorsTableAnnotationComposer({ required super.$db, required super.$table, super.joinBuilder, super.$addJoinBuilderToRootComposer, super.$removeJoinBuilderFromRootComposer, }); - GeneratedColumn get id => - $composableBuilder(column: $table.id, builder: (column) => column); - GeneratedColumn get provider => $composableBuilder(column: $table.provider, builder: (column) => column); - GeneratedColumn get method => - $composableBuilder(column: $table.method, builder: (column) => column); + GeneratedColumn get colorType => + $composableBuilder(column: $table.colorType, builder: (column) => column); - GeneratedColumn get minutesBefore => $composableBuilder( - column: $table.minutesBefore, + GeneratedColumn get colorId => + $composableBuilder(column: $table.colorId, builder: (column) => column); + + GeneratedColumn get background => $composableBuilder( + column: $table.background, builder: (column) => column, ); - GeneratedColumn get absoluteTime => $composableBuilder( - column: $table.absoluteTime, + GeneratedColumn get foreground => $composableBuilder( + column: $table.foreground, builder: (column) => column, ); - GeneratedColumn get enabled => - $composableBuilder(column: $table.enabled, builder: (column) => column); - GeneratedColumn get rawJson => $composableBuilder(column: $table.rawJson, builder: (column) => column); - - $$CalendarEventsTableAnnotationComposer get calendarEventId { - final $$CalendarEventsTableAnnotationComposer composer = $composerBuilder( - composer: this, - getCurrentColumn: (t) => t.calendarEventId, - referencedTable: $db.calendarEvents, - getReferencedColumn: (t) => t.id, - builder: - ( - joinBuilder, { - $addJoinBuilderToRootComposer, - $removeJoinBuilderFromRootComposer, - }) => $$CalendarEventsTableAnnotationComposer( - $db: $db, - $table: $db.calendarEvents, - $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, - joinBuilder: joinBuilder, - $removeJoinBuilderFromRootComposer: - $removeJoinBuilderFromRootComposer, - ), - ); - return composer; - } } -class $$CalendarEventRemindersTableTableManager +class $$CalendarColorsTableTableManager extends RootTableManager< _$AppDatabase, - $CalendarEventRemindersTable, - CalendarEventReminder, - $$CalendarEventRemindersTableFilterComposer, - $$CalendarEventRemindersTableOrderingComposer, - $$CalendarEventRemindersTableAnnotationComposer, - $$CalendarEventRemindersTableCreateCompanionBuilder, - $$CalendarEventRemindersTableUpdateCompanionBuilder, - (CalendarEventReminder, $$CalendarEventRemindersTableReferences), - CalendarEventReminder, - PrefetchHooks Function({bool calendarEventId}) + $CalendarColorsTable, + CalendarColor, + $$CalendarColorsTableFilterComposer, + $$CalendarColorsTableOrderingComposer, + $$CalendarColorsTableAnnotationComposer, + $$CalendarColorsTableCreateCompanionBuilder, + $$CalendarColorsTableUpdateCompanionBuilder, + ( + CalendarColor, + BaseReferences<_$AppDatabase, $CalendarColorsTable, CalendarColor>, + ), + CalendarColor, + PrefetchHooks Function() > { - $$CalendarEventRemindersTableTableManager( + $$CalendarColorsTableTableManager( _$AppDatabase db, - $CalendarEventRemindersTable table, + $CalendarColorsTable table, ) : super( TableManagerState( db: db, table: table, createFilteringComposer: () => - $$CalendarEventRemindersTableFilterComposer( - $db: db, - $table: table, - ), + $$CalendarColorsTableFilterComposer($db: db, $table: table), createOrderingComposer: () => - $$CalendarEventRemindersTableOrderingComposer( - $db: db, - $table: table, - ), + $$CalendarColorsTableOrderingComposer($db: db, $table: table), createComputedFieldComposer: () => - $$CalendarEventRemindersTableAnnotationComposer( - $db: db, - $table: table, - ), + $$CalendarColorsTableAnnotationComposer($db: db, $table: table), updateCompanionCallback: ({ - Value id = const Value.absent(), - Value calendarEventId = const Value.absent(), Value provider = const Value.absent(), - Value method = const Value.absent(), - Value minutesBefore = const Value.absent(), - Value absoluteTime = const Value.absent(), - Value enabled = const Value.absent(), + Value colorType = const Value.absent(), + Value colorId = const Value.absent(), + Value background = const Value.absent(), + Value foreground = const Value.absent(), Value rawJson = const Value.absent(), Value rowid = const Value.absent(), - }) => CalendarEventRemindersCompanion( - id: id, - calendarEventId: calendarEventId, + }) => CalendarColorsCompanion( provider: provider, - method: method, - minutesBefore: minutesBefore, - absoluteTime: absoluteTime, - enabled: enabled, + colorType: colorType, + colorId: colorId, + background: background, + foreground: foreground, rawJson: rawJson, rowid: rowid, ), createCompanionCallback: ({ - required String id, - required String calendarEventId, required String provider, - Value method = const Value.absent(), - Value minutesBefore = const Value.absent(), - Value absoluteTime = const Value.absent(), - Value enabled = const Value.absent(), + required String colorType, + required String colorId, + required String background, + Value foreground = const Value.absent(), Value rawJson = const Value.absent(), Value rowid = const Value.absent(), - }) => CalendarEventRemindersCompanion.insert( - id: id, - calendarEventId: calendarEventId, + }) => CalendarColorsCompanion.insert( provider: provider, - method: method, - minutesBefore: minutesBefore, - absoluteTime: absoluteTime, - enabled: enabled, + colorType: colorType, + colorId: colorId, + background: background, + foreground: foreground, rawJson: rawJson, rowid: rowid, ), withReferenceMapper: (p0) => p0 .map( (e) => ( - e.readTable(table), - $$CalendarEventRemindersTableReferences(db, table, e), + e.readTable<$CalendarColorsTable, CalendarColor>(table), + BaseReferences< + _$AppDatabase, + $CalendarColorsTable, + CalendarColor + >(db, table, e), ), ) .toList(), - prefetchHooksCallback: ({calendarEventId = false}) { - return PrefetchHooks( - db: db, - explicitlyWatchedTables: [], - addJoins: - < - T extends TableManagerState< - dynamic, - dynamic, - dynamic, - dynamic, - dynamic, - dynamic, - dynamic, - dynamic, - dynamic, - dynamic, - dynamic - > - >(state) { - if (calendarEventId) { - state = - state.withJoin( - currentTable: table, - currentColumn: table.calendarEventId, - referencedTable: - $$CalendarEventRemindersTableReferences - ._calendarEventIdTable(db), - referencedColumn: - $$CalendarEventRemindersTableReferences - ._calendarEventIdTable(db) - .id, - ) - as T; - } - - return state; - }, - getPrefetchedDataCallback: (items) async { - return []; - }, - ); - }, + prefetchHooksCallback: null, ), ); } -typedef $$CalendarEventRemindersTableProcessedTableManager = +typedef $$CalendarColorsTableProcessedTableManager = ProcessedTableManager< _$AppDatabase, - $CalendarEventRemindersTable, - CalendarEventReminder, - $$CalendarEventRemindersTableFilterComposer, - $$CalendarEventRemindersTableOrderingComposer, - $$CalendarEventRemindersTableAnnotationComposer, - $$CalendarEventRemindersTableCreateCompanionBuilder, - $$CalendarEventRemindersTableUpdateCompanionBuilder, - (CalendarEventReminder, $$CalendarEventRemindersTableReferences), - CalendarEventReminder, - PrefetchHooks Function({bool calendarEventId}) + $CalendarColorsTable, + CalendarColor, + $$CalendarColorsTableFilterComposer, + $$CalendarColorsTableOrderingComposer, + $$CalendarColorsTableAnnotationComposer, + $$CalendarColorsTableCreateCompanionBuilder, + $$CalendarColorsTableUpdateCompanionBuilder, + ( + CalendarColor, + BaseReferences<_$AppDatabase, $CalendarColorsTable, CalendarColor>, + ), + CalendarColor, + PrefetchHooks Function() >; -typedef $$SyncCursorsTableCreateCompanionBuilder = - SyncCursorsCompanion Function({ +typedef $$ScheduleItemOverridesTableCreateCompanionBuilder = + ScheduleItemOverridesCompanion Function({ required String id, required String accountId, - Value projectionSourceId, - required String provider, - required String transport, - required String syncScopeKind, - Value davCollectionId, - required String cursorKind, - required String cursorValue, - Value rangeStart, - Value rangeEnd, - Value baselineGeneration, - Value inProgressCursor, - Value inProgressGeneration, - Value lastCompleteSyncAt, - Value lastFailureCode, - Value stateSchemaVersion, - Value stateJson, + required String sourceType, + required String sourceId, + required String overrideJson, + required int createdAtLocal, + required int updatedAtLocal, Value rowid, }); -typedef $$SyncCursorsTableUpdateCompanionBuilder = - SyncCursorsCompanion Function({ +typedef $$ScheduleItemOverridesTableUpdateCompanionBuilder = + ScheduleItemOverridesCompanion Function({ Value id, Value accountId, - Value projectionSourceId, - Value provider, - Value transport, - Value syncScopeKind, - Value davCollectionId, - Value cursorKind, - Value cursorValue, - Value rangeStart, - Value rangeEnd, - Value baselineGeneration, - Value inProgressCursor, - Value inProgressGeneration, - Value lastCompleteSyncAt, - Value lastFailureCode, - Value stateSchemaVersion, - Value stateJson, + Value sourceType, + Value sourceId, + Value overrideJson, + Value createdAtLocal, + Value updatedAtLocal, Value rowid, }); -final class $$SyncCursorsTableReferences - extends BaseReferences<_$AppDatabase, $SyncCursorsTable, SyncCursor> { - $$SyncCursorsTableReferences(super.$_db, super.$_table, super.$_typedResult); +final class $$ScheduleItemOverridesTableReferences + extends + BaseReferences< + _$AppDatabase, + $ScheduleItemOverridesTable, + ScheduleItemOverride + > { + $$ScheduleItemOverridesTableReferences( + super.$_db, + super.$_table, + super.$_typedResult, + ); - static $AccountsTable _accountIdTable(_$AppDatabase db) => - db.accounts.createAlias( - $_aliasNameGenerator(db.syncCursors.accountId, db.accounts.id), - ); + static $AccountsTable _accountIdTable(_$AppDatabase db) => db.accounts + .createAlias('schedule_item_overrides__account_id__accounts__id'); $$AccountsTableProcessedTableManager get accountId { final $_column = $_itemColumn('account_id')!; @@ -42729,55 +44530,11 @@ final class $$SyncCursorsTableReferences manager.$state.copyWith(prefetchedData: [item]), ); } - - static $CalendarSourcesTable _projectionSourceIdTable(_$AppDatabase db) => - db.calendarSources.createAlias( - $_aliasNameGenerator( - db.syncCursors.projectionSourceId, - db.calendarSources.id, - ), - ); - - $$CalendarSourcesTableProcessedTableManager? get projectionSourceId { - final $_column = $_itemColumn('projection_source_id'); - if ($_column == null) return null; - final manager = $$CalendarSourcesTableTableManager( - $_db, - $_db.calendarSources, - ).filter((f) => f.id.sqlEquals($_column)); - final item = $_typedResult.readTableOrNull(_projectionSourceIdTable($_db)); - if (item == null) return manager; - return ProcessedTableManager( - manager.$state.copyWith(prefetchedData: [item]), - ); - } - - static $DavCollectionsTable _davCollectionIdTable(_$AppDatabase db) => - db.davCollections.createAlias( - $_aliasNameGenerator( - db.syncCursors.davCollectionId, - db.davCollections.id, - ), - ); - - $$DavCollectionsTableProcessedTableManager? get davCollectionId { - final $_column = $_itemColumn('dav_collection_id'); - if ($_column == null) return null; - final manager = $$DavCollectionsTableTableManager( - $_db, - $_db.davCollections, - ).filter((f) => f.id.sqlEquals($_column)); - final item = $_typedResult.readTableOrNull(_davCollectionIdTable($_db)); - if (item == null) return manager; - return ProcessedTableManager( - manager.$state.copyWith(prefetchedData: [item]), - ); - } } -class $$SyncCursorsTableFilterComposer - extends Composer<_$AppDatabase, $SyncCursorsTable> { - $$SyncCursorsTableFilterComposer({ +class $$ScheduleItemOverridesTableFilterComposer + extends Composer<_$AppDatabase, $ScheduleItemOverridesTable> { + $$ScheduleItemOverridesTableFilterComposer({ required super.$db, required super.$table, super.joinBuilder, @@ -42789,73 +44546,28 @@ class $$SyncCursorsTableFilterComposer builder: (column) => ColumnFilters(column), ); - ColumnFilters get provider => $composableBuilder( - column: $table.provider, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get transport => $composableBuilder( - column: $table.transport, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get syncScopeKind => $composableBuilder( - column: $table.syncScopeKind, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get cursorKind => $composableBuilder( - column: $table.cursorKind, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get cursorValue => $composableBuilder( - column: $table.cursorValue, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get rangeStart => $composableBuilder( - column: $table.rangeStart, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get rangeEnd => $composableBuilder( - column: $table.rangeEnd, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get baselineGeneration => $composableBuilder( - column: $table.baselineGeneration, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get inProgressCursor => $composableBuilder( - column: $table.inProgressCursor, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get inProgressGeneration => $composableBuilder( - column: $table.inProgressGeneration, + ColumnFilters get sourceType => $composableBuilder( + column: $table.sourceType, builder: (column) => ColumnFilters(column), ); - ColumnFilters get lastCompleteSyncAt => $composableBuilder( - column: $table.lastCompleteSyncAt, + ColumnFilters get sourceId => $composableBuilder( + column: $table.sourceId, builder: (column) => ColumnFilters(column), ); - ColumnFilters get lastFailureCode => $composableBuilder( - column: $table.lastFailureCode, + ColumnFilters get overrideJson => $composableBuilder( + column: $table.overrideJson, builder: (column) => ColumnFilters(column), ); - ColumnFilters get stateSchemaVersion => $composableBuilder( - column: $table.stateSchemaVersion, + ColumnFilters get createdAtLocal => $composableBuilder( + column: $table.createdAtLocal, builder: (column) => ColumnFilters(column), ); - ColumnFilters get stateJson => $composableBuilder( - column: $table.stateJson, + ColumnFilters get updatedAtLocal => $composableBuilder( + column: $table.updatedAtLocal, builder: (column) => ColumnFilters(column), ); @@ -42879,137 +44591,46 @@ class $$SyncCursorsTableFilterComposer $removeJoinBuilderFromRootComposer, ), ); - return composer; - } - - $$CalendarSourcesTableFilterComposer get projectionSourceId { - final $$CalendarSourcesTableFilterComposer composer = $composerBuilder( - composer: this, - getCurrentColumn: (t) => t.projectionSourceId, - referencedTable: $db.calendarSources, - getReferencedColumn: (t) => t.id, - builder: - ( - joinBuilder, { - $addJoinBuilderToRootComposer, - $removeJoinBuilderFromRootComposer, - }) => $$CalendarSourcesTableFilterComposer( - $db: $db, - $table: $db.calendarSources, - $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, - joinBuilder: joinBuilder, - $removeJoinBuilderFromRootComposer: - $removeJoinBuilderFromRootComposer, - ), - ); - return composer; - } - - $$DavCollectionsTableFilterComposer get davCollectionId { - final $$DavCollectionsTableFilterComposer composer = $composerBuilder( - composer: this, - getCurrentColumn: (t) => t.davCollectionId, - referencedTable: $db.davCollections, - getReferencedColumn: (t) => t.id, - builder: - ( - joinBuilder, { - $addJoinBuilderToRootComposer, - $removeJoinBuilderFromRootComposer, - }) => $$DavCollectionsTableFilterComposer( - $db: $db, - $table: $db.davCollections, - $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, - joinBuilder: joinBuilder, - $removeJoinBuilderFromRootComposer: - $removeJoinBuilderFromRootComposer, - ), - ); - return composer; - } -} - -class $$SyncCursorsTableOrderingComposer - extends Composer<_$AppDatabase, $SyncCursorsTable> { - $$SyncCursorsTableOrderingComposer({ - required super.$db, - required super.$table, - super.joinBuilder, - super.$addJoinBuilderToRootComposer, - super.$removeJoinBuilderFromRootComposer, - }); - ColumnOrderings get id => $composableBuilder( - column: $table.id, - builder: (column) => ColumnOrderings(column), - ); - - ColumnOrderings get provider => $composableBuilder( - column: $table.provider, - builder: (column) => ColumnOrderings(column), - ); - - ColumnOrderings get transport => $composableBuilder( - column: $table.transport, - builder: (column) => ColumnOrderings(column), - ); - - ColumnOrderings get syncScopeKind => $composableBuilder( - column: $table.syncScopeKind, - builder: (column) => ColumnOrderings(column), - ); - - ColumnOrderings get cursorKind => $composableBuilder( - column: $table.cursorKind, - builder: (column) => ColumnOrderings(column), - ); - - ColumnOrderings get cursorValue => $composableBuilder( - column: $table.cursorValue, - builder: (column) => ColumnOrderings(column), - ); - - ColumnOrderings get rangeStart => $composableBuilder( - column: $table.rangeStart, - builder: (column) => ColumnOrderings(column), - ); - - ColumnOrderings get rangeEnd => $composableBuilder( - column: $table.rangeEnd, - builder: (column) => ColumnOrderings(column), - ); - - ColumnOrderings get baselineGeneration => $composableBuilder( - column: $table.baselineGeneration, - builder: (column) => ColumnOrderings(column), - ); + return composer; + } +} - ColumnOrderings get inProgressCursor => $composableBuilder( - column: $table.inProgressCursor, +class $$ScheduleItemOverridesTableOrderingComposer + extends Composer<_$AppDatabase, $ScheduleItemOverridesTable> { + $$ScheduleItemOverridesTableOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnOrderings get id => $composableBuilder( + column: $table.id, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get inProgressGeneration => $composableBuilder( - column: $table.inProgressGeneration, + ColumnOrderings get sourceType => $composableBuilder( + column: $table.sourceType, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get lastCompleteSyncAt => $composableBuilder( - column: $table.lastCompleteSyncAt, + ColumnOrderings get sourceId => $composableBuilder( + column: $table.sourceId, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get lastFailureCode => $composableBuilder( - column: $table.lastFailureCode, + ColumnOrderings get overrideJson => $composableBuilder( + column: $table.overrideJson, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get stateSchemaVersion => $composableBuilder( - column: $table.stateSchemaVersion, + ColumnOrderings get createdAtLocal => $composableBuilder( + column: $table.createdAtLocal, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get stateJson => $composableBuilder( - column: $table.stateJson, + ColumnOrderings get updatedAtLocal => $composableBuilder( + column: $table.updatedAtLocal, builder: (column) => ColumnOrderings(column), ); @@ -43035,57 +44656,11 @@ class $$SyncCursorsTableOrderingComposer ); return composer; } - - $$CalendarSourcesTableOrderingComposer get projectionSourceId { - final $$CalendarSourcesTableOrderingComposer composer = $composerBuilder( - composer: this, - getCurrentColumn: (t) => t.projectionSourceId, - referencedTable: $db.calendarSources, - getReferencedColumn: (t) => t.id, - builder: - ( - joinBuilder, { - $addJoinBuilderToRootComposer, - $removeJoinBuilderFromRootComposer, - }) => $$CalendarSourcesTableOrderingComposer( - $db: $db, - $table: $db.calendarSources, - $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, - joinBuilder: joinBuilder, - $removeJoinBuilderFromRootComposer: - $removeJoinBuilderFromRootComposer, - ), - ); - return composer; - } - - $$DavCollectionsTableOrderingComposer get davCollectionId { - final $$DavCollectionsTableOrderingComposer composer = $composerBuilder( - composer: this, - getCurrentColumn: (t) => t.davCollectionId, - referencedTable: $db.davCollections, - getReferencedColumn: (t) => t.id, - builder: - ( - joinBuilder, { - $addJoinBuilderToRootComposer, - $removeJoinBuilderFromRootComposer, - }) => $$DavCollectionsTableOrderingComposer( - $db: $db, - $table: $db.davCollections, - $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, - joinBuilder: joinBuilder, - $removeJoinBuilderFromRootComposer: - $removeJoinBuilderFromRootComposer, - ), - ); - return composer; - } } -class $$SyncCursorsTableAnnotationComposer - extends Composer<_$AppDatabase, $SyncCursorsTable> { - $$SyncCursorsTableAnnotationComposer({ +class $$ScheduleItemOverridesTableAnnotationComposer + extends Composer<_$AppDatabase, $ScheduleItemOverridesTable> { + $$ScheduleItemOverridesTableAnnotationComposer({ required super.$db, required super.$table, super.joinBuilder, @@ -43095,68 +44670,29 @@ class $$SyncCursorsTableAnnotationComposer GeneratedColumn get id => $composableBuilder(column: $table.id, builder: (column) => column); - GeneratedColumn get provider => - $composableBuilder(column: $table.provider, builder: (column) => column); - - GeneratedColumn get transport => - $composableBuilder(column: $table.transport, builder: (column) => column); - - GeneratedColumn get syncScopeKind => $composableBuilder( - column: $table.syncScopeKind, - builder: (column) => column, - ); - - GeneratedColumn get cursorKind => $composableBuilder( - column: $table.cursorKind, - builder: (column) => column, - ); - - GeneratedColumn get cursorValue => $composableBuilder( - column: $table.cursorValue, - builder: (column) => column, - ); - - GeneratedColumn get rangeStart => $composableBuilder( - column: $table.rangeStart, - builder: (column) => column, - ); - - GeneratedColumn get rangeEnd => - $composableBuilder(column: $table.rangeEnd, builder: (column) => column); - - GeneratedColumn get baselineGeneration => $composableBuilder( - column: $table.baselineGeneration, - builder: (column) => column, - ); - - GeneratedColumn get inProgressCursor => $composableBuilder( - column: $table.inProgressCursor, + GeneratedColumn get sourceType => $composableBuilder( + column: $table.sourceType, builder: (column) => column, ); - GeneratedColumn get inProgressGeneration => $composableBuilder( - column: $table.inProgressGeneration, - builder: (column) => column, - ); + GeneratedColumn get sourceId => + $composableBuilder(column: $table.sourceId, builder: (column) => column); - GeneratedColumn get lastCompleteSyncAt => $composableBuilder( - column: $table.lastCompleteSyncAt, + GeneratedColumn get overrideJson => $composableBuilder( + column: $table.overrideJson, builder: (column) => column, ); - GeneratedColumn get lastFailureCode => $composableBuilder( - column: $table.lastFailureCode, + GeneratedColumn get createdAtLocal => $composableBuilder( + column: $table.createdAtLocal, builder: (column) => column, ); - GeneratedColumn get stateSchemaVersion => $composableBuilder( - column: $table.stateSchemaVersion, + GeneratedColumn get updatedAtLocal => $composableBuilder( + column: $table.updatedAtLocal, builder: (column) => column, ); - GeneratedColumn get stateJson => - $composableBuilder(column: $table.stateJson, builder: (column) => column); - $$AccountsTableAnnotationComposer get accountId { final $$AccountsTableAnnotationComposer composer = $composerBuilder( composer: this, @@ -43179,553 +44715,826 @@ class $$SyncCursorsTableAnnotationComposer ); return composer; } - - $$CalendarSourcesTableAnnotationComposer get projectionSourceId { - final $$CalendarSourcesTableAnnotationComposer composer = $composerBuilder( - composer: this, - getCurrentColumn: (t) => t.projectionSourceId, - referencedTable: $db.calendarSources, - getReferencedColumn: (t) => t.id, - builder: - ( - joinBuilder, { - $addJoinBuilderToRootComposer, - $removeJoinBuilderFromRootComposer, - }) => $$CalendarSourcesTableAnnotationComposer( - $db: $db, - $table: $db.calendarSources, - $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, - joinBuilder: joinBuilder, - $removeJoinBuilderFromRootComposer: - $removeJoinBuilderFromRootComposer, - ), - ); - return composer; - } - - $$DavCollectionsTableAnnotationComposer get davCollectionId { - final $$DavCollectionsTableAnnotationComposer composer = $composerBuilder( - composer: this, - getCurrentColumn: (t) => t.davCollectionId, - referencedTable: $db.davCollections, - getReferencedColumn: (t) => t.id, - builder: - ( - joinBuilder, { - $addJoinBuilderToRootComposer, - $removeJoinBuilderFromRootComposer, - }) => $$DavCollectionsTableAnnotationComposer( - $db: $db, - $table: $db.davCollections, - $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, - joinBuilder: joinBuilder, - $removeJoinBuilderFromRootComposer: - $removeJoinBuilderFromRootComposer, - ), - ); - return composer; - } } -class $$SyncCursorsTableTableManager +class $$ScheduleItemOverridesTableTableManager extends RootTableManager< _$AppDatabase, - $SyncCursorsTable, - SyncCursor, - $$SyncCursorsTableFilterComposer, - $$SyncCursorsTableOrderingComposer, - $$SyncCursorsTableAnnotationComposer, - $$SyncCursorsTableCreateCompanionBuilder, - $$SyncCursorsTableUpdateCompanionBuilder, - (SyncCursor, $$SyncCursorsTableReferences), - SyncCursor, - PrefetchHooks Function({ - bool accountId, - bool projectionSourceId, - bool davCollectionId, - }) + $ScheduleItemOverridesTable, + ScheduleItemOverride, + $$ScheduleItemOverridesTableFilterComposer, + $$ScheduleItemOverridesTableOrderingComposer, + $$ScheduleItemOverridesTableAnnotationComposer, + $$ScheduleItemOverridesTableCreateCompanionBuilder, + $$ScheduleItemOverridesTableUpdateCompanionBuilder, + (ScheduleItemOverride, $$ScheduleItemOverridesTableReferences), + ScheduleItemOverride, + PrefetchHooks Function({bool accountId}) > { - $$SyncCursorsTableTableManager(_$AppDatabase db, $SyncCursorsTable table) - : super( + $$ScheduleItemOverridesTableTableManager( + _$AppDatabase db, + $ScheduleItemOverridesTable table, + ) : super( TableManagerState( db: db, table: table, createFilteringComposer: () => - $$SyncCursorsTableFilterComposer($db: db, $table: table), + $$ScheduleItemOverridesTableFilterComposer( + $db: db, + $table: table, + ), createOrderingComposer: () => - $$SyncCursorsTableOrderingComposer($db: db, $table: table), + $$ScheduleItemOverridesTableOrderingComposer( + $db: db, + $table: table, + ), createComputedFieldComposer: () => - $$SyncCursorsTableAnnotationComposer($db: db, $table: table), - updateCompanionCallback: - ({ - Value id = const Value.absent(), - Value accountId = const Value.absent(), - Value projectionSourceId = const Value.absent(), - Value provider = const Value.absent(), - Value transport = const Value.absent(), - Value syncScopeKind = const Value.absent(), - Value davCollectionId = const Value.absent(), - Value cursorKind = const Value.absent(), - Value cursorValue = const Value.absent(), - Value rangeStart = const Value.absent(), - Value rangeEnd = const Value.absent(), - Value baselineGeneration = const Value.absent(), - Value inProgressCursor = const Value.absent(), - Value inProgressGeneration = const Value.absent(), - Value lastCompleteSyncAt = const Value.absent(), - Value lastFailureCode = const Value.absent(), - Value stateSchemaVersion = const Value.absent(), - Value stateJson = const Value.absent(), + $$ScheduleItemOverridesTableAnnotationComposer( + $db: db, + $table: table, + ), + updateCompanionCallback: + ({ + Value id = const Value.absent(), + Value accountId = const Value.absent(), + Value sourceType = const Value.absent(), + Value sourceId = const Value.absent(), + Value overrideJson = const Value.absent(), + Value createdAtLocal = const Value.absent(), + Value updatedAtLocal = const Value.absent(), Value rowid = const Value.absent(), - }) => SyncCursorsCompanion( + }) => ScheduleItemOverridesCompanion( id: id, accountId: accountId, - projectionSourceId: projectionSourceId, - provider: provider, - transport: transport, - syncScopeKind: syncScopeKind, - davCollectionId: davCollectionId, - cursorKind: cursorKind, - cursorValue: cursorValue, - rangeStart: rangeStart, - rangeEnd: rangeEnd, - baselineGeneration: baselineGeneration, - inProgressCursor: inProgressCursor, - inProgressGeneration: inProgressGeneration, - lastCompleteSyncAt: lastCompleteSyncAt, - lastFailureCode: lastFailureCode, - stateSchemaVersion: stateSchemaVersion, - stateJson: stateJson, + sourceType: sourceType, + sourceId: sourceId, + overrideJson: overrideJson, + createdAtLocal: createdAtLocal, + updatedAtLocal: updatedAtLocal, rowid: rowid, ), createCompanionCallback: ({ required String id, required String accountId, - Value projectionSourceId = const Value.absent(), - required String provider, - required String transport, - required String syncScopeKind, - Value davCollectionId = const Value.absent(), - required String cursorKind, - required String cursorValue, - Value rangeStart = const Value.absent(), - Value rangeEnd = const Value.absent(), - Value baselineGeneration = const Value.absent(), - Value inProgressCursor = const Value.absent(), - Value inProgressGeneration = const Value.absent(), - Value lastCompleteSyncAt = const Value.absent(), - Value lastFailureCode = const Value.absent(), - Value stateSchemaVersion = const Value.absent(), - Value stateJson = const Value.absent(), + required String sourceType, + required String sourceId, + required String overrideJson, + required int createdAtLocal, + required int updatedAtLocal, Value rowid = const Value.absent(), - }) => SyncCursorsCompanion.insert( + }) => ScheduleItemOverridesCompanion.insert( id: id, accountId: accountId, - projectionSourceId: projectionSourceId, - provider: provider, - transport: transport, - syncScopeKind: syncScopeKind, - davCollectionId: davCollectionId, - cursorKind: cursorKind, - cursorValue: cursorValue, - rangeStart: rangeStart, - rangeEnd: rangeEnd, - baselineGeneration: baselineGeneration, - inProgressCursor: inProgressCursor, - inProgressGeneration: inProgressGeneration, - lastCompleteSyncAt: lastCompleteSyncAt, - lastFailureCode: lastFailureCode, - stateSchemaVersion: stateSchemaVersion, - stateJson: stateJson, + sourceType: sourceType, + sourceId: sourceId, + overrideJson: overrideJson, + createdAtLocal: createdAtLocal, + updatedAtLocal: updatedAtLocal, rowid: rowid, ), withReferenceMapper: (p0) => p0 .map( (e) => ( - e.readTable(table), - $$SyncCursorsTableReferences(db, table, e), + e.readTable< + $ScheduleItemOverridesTable, + ScheduleItemOverride + >(table), + $$ScheduleItemOverridesTableReferences(db, table, e), ), ) .toList(), - prefetchHooksCallback: - ({ - accountId = false, - projectionSourceId = false, - davCollectionId = false, - }) { - return PrefetchHooks( - db: db, - explicitlyWatchedTables: [], - addJoins: - < - T extends TableManagerState< - dynamic, - dynamic, - dynamic, - dynamic, - dynamic, - dynamic, - dynamic, - dynamic, - dynamic, - dynamic, - dynamic - > - >(state) { - if (accountId) { - state = - state.withJoin( - currentTable: table, - currentColumn: table.accountId, - referencedTable: - $$SyncCursorsTableReferences - ._accountIdTable(db), - referencedColumn: - $$SyncCursorsTableReferences - ._accountIdTable(db) - .id, - ) - as T; - } - if (projectionSourceId) { - state = - state.withJoin( - currentTable: table, - currentColumn: table.projectionSourceId, - referencedTable: - $$SyncCursorsTableReferences - ._projectionSourceIdTable(db), - referencedColumn: - $$SyncCursorsTableReferences - ._projectionSourceIdTable(db) - .id, - ) - as T; - } - if (davCollectionId) { - state = - state.withJoin( - currentTable: table, - currentColumn: table.davCollectionId, - referencedTable: - $$SyncCursorsTableReferences - ._davCollectionIdTable(db), - referencedColumn: - $$SyncCursorsTableReferences - ._davCollectionIdTable(db) - .id, - ) - as T; - } + prefetchHooksCallback: ({accountId = false}) { + return PrefetchHooks( + db: db, + explicitlyWatchedTables: [], + addJoins: + < + T extends TableManagerState< + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic + > + >(state) { + if (accountId) { + state = + state.withJoin( + currentTable: table, + currentColumn: table.accountId, + referencedTable: + $$ScheduleItemOverridesTableReferences + ._accountIdTable(db), + referencedColumn: + $$ScheduleItemOverridesTableReferences + ._accountIdTable(db) + .id, + ) + as T; + } - return state; - }, - getPrefetchedDataCallback: (items) async { - return []; + return state; }, - ); + getPrefetchedDataCallback: (items) async { + return []; }, + ); + }, ), ); } -typedef $$SyncCursorsTableProcessedTableManager = +typedef $$ScheduleItemOverridesTableProcessedTableManager = ProcessedTableManager< _$AppDatabase, - $SyncCursorsTable, - SyncCursor, - $$SyncCursorsTableFilterComposer, - $$SyncCursorsTableOrderingComposer, - $$SyncCursorsTableAnnotationComposer, - $$SyncCursorsTableCreateCompanionBuilder, - $$SyncCursorsTableUpdateCompanionBuilder, - (SyncCursor, $$SyncCursorsTableReferences), - SyncCursor, - PrefetchHooks Function({ - bool accountId, - bool projectionSourceId, - bool davCollectionId, - }) + $ScheduleItemOverridesTable, + ScheduleItemOverride, + $$ScheduleItemOverridesTableFilterComposer, + $$ScheduleItemOverridesTableOrderingComposer, + $$ScheduleItemOverridesTableAnnotationComposer, + $$ScheduleItemOverridesTableCreateCompanionBuilder, + $$ScheduleItemOverridesTableUpdateCompanionBuilder, + (ScheduleItemOverride, $$ScheduleItemOverridesTableReferences), + ScheduleItemOverride, + PrefetchHooks Function({bool accountId}) >; -typedef $$CalendarColorsTableCreateCompanionBuilder = - CalendarColorsCompanion Function({ - required String provider, - required String colorType, - required String colorId, - required String background, - Value foreground, - Value rawJson, +typedef $$NotificationScheduleTableCreateCompanionBuilder = + NotificationScheduleCompanion Function({ + required String id, + Value generation, + required String accountId, + required String sourceType, + required String sourceId, + required int scheduledAtUtc, + required String title, + Value body, + Value sentAtUtc, + Value dismissedAtUtc, + Value snoozedUntilUtc, + required int createdAtLocal, + required int updatedAtLocal, Value rowid, }); -typedef $$CalendarColorsTableUpdateCompanionBuilder = - CalendarColorsCompanion Function({ - Value provider, - Value colorType, - Value colorId, - Value background, - Value foreground, - Value rawJson, +typedef $$NotificationScheduleTableUpdateCompanionBuilder = + NotificationScheduleCompanion Function({ + Value id, + Value generation, + Value accountId, + Value sourceType, + Value sourceId, + Value scheduledAtUtc, + Value title, + Value body, + Value sentAtUtc, + Value dismissedAtUtc, + Value snoozedUntilUtc, + Value createdAtLocal, + Value updatedAtLocal, Value rowid, }); -class $$CalendarColorsTableFilterComposer - extends Composer<_$AppDatabase, $CalendarColorsTable> { - $$CalendarColorsTableFilterComposer({ +final class $$NotificationScheduleTableReferences + extends + BaseReferences< + _$AppDatabase, + $NotificationScheduleTable, + NotificationScheduleData + > { + $$NotificationScheduleTableReferences( + super.$_db, + super.$_table, + super.$_typedResult, + ); + + static $AccountsTable _accountIdTable(_$AppDatabase db) => db.accounts + .createAlias('notification_schedule__account_id__accounts__id'); + + $$AccountsTableProcessedTableManager get accountId { + final $_column = $_itemColumn('account_id')!; + + final manager = $$AccountsTableTableManager( + $_db, + $_db.accounts, + ).filter((f) => f.id.sqlEquals($_column)); + final item = $_typedResult.readTableOrNull(_accountIdTable($_db)); + if (item == null) return manager; + return ProcessedTableManager( + manager.$state.copyWith(prefetchedData: [item]), + ); + } + + static MultiTypedResultKey< + $AndroidNotificationMappingsTable, + List + > + _androidNotificationMappingsRefsTable( + _$AppDatabase db, + ) => MultiTypedResultKey.fromTable( + db.androidNotificationMappings, + aliasName: + 'notification_schedule__id__android_notification_mappings__schedule_id', + ); + + $$AndroidNotificationMappingsTableProcessedTableManager + get androidNotificationMappingsRefs { + final manager = $$AndroidNotificationMappingsTableTableManager( + $_db, + $_db.androidNotificationMappings, + ).filter((f) => f.scheduleId.id.sqlEquals($_itemColumn('id')!)); + + final cache = $_typedResult.readTableOrNull( + _androidNotificationMappingsRefsTable($_db), + ); + return ProcessedTableManager( + manager.$state.copyWith(prefetchedData: cache), + ); + } +} + +class $$NotificationScheduleTableFilterComposer + extends Composer<_$AppDatabase, $NotificationScheduleTable> { + $$NotificationScheduleTableFilterComposer({ required super.$db, required super.$table, super.joinBuilder, super.$addJoinBuilderToRootComposer, super.$removeJoinBuilderFromRootComposer, }); - ColumnFilters get provider => $composableBuilder( - column: $table.provider, + ColumnFilters get id => $composableBuilder( + column: $table.id, builder: (column) => ColumnFilters(column), ); - ColumnFilters get colorType => $composableBuilder( - column: $table.colorType, + ColumnFilters get generation => $composableBuilder( + column: $table.generation, builder: (column) => ColumnFilters(column), ); - ColumnFilters get colorId => $composableBuilder( - column: $table.colorId, + ColumnFilters get sourceType => $composableBuilder( + column: $table.sourceType, builder: (column) => ColumnFilters(column), ); - ColumnFilters get background => $composableBuilder( - column: $table.background, + ColumnFilters get sourceId => $composableBuilder( + column: $table.sourceId, builder: (column) => ColumnFilters(column), ); - ColumnFilters get foreground => $composableBuilder( - column: $table.foreground, + ColumnFilters get scheduledAtUtc => $composableBuilder( + column: $table.scheduledAtUtc, builder: (column) => ColumnFilters(column), ); - ColumnFilters get rawJson => $composableBuilder( - column: $table.rawJson, + ColumnFilters get title => $composableBuilder( + column: $table.title, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get body => $composableBuilder( + column: $table.body, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get sentAtUtc => $composableBuilder( + column: $table.sentAtUtc, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get dismissedAtUtc => $composableBuilder( + column: $table.dismissedAtUtc, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get snoozedUntilUtc => $composableBuilder( + column: $table.snoozedUntilUtc, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get createdAtLocal => $composableBuilder( + column: $table.createdAtLocal, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get updatedAtLocal => $composableBuilder( + column: $table.updatedAtLocal, builder: (column) => ColumnFilters(column), ); + + $$AccountsTableFilterComposer get accountId { + final $$AccountsTableFilterComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.accountId, + referencedTable: $db.accounts, + getReferencedColumn: (t) => t.id, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$AccountsTableFilterComposer( + $db: $db, + $table: $db.accounts, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return composer; + } + + Expression androidNotificationMappingsRefs( + Expression Function( + $$AndroidNotificationMappingsTableFilterComposer f, + ) + f, + ) { + final $$AndroidNotificationMappingsTableFilterComposer composer = + $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.id, + referencedTable: $db.androidNotificationMappings, + getReferencedColumn: (t) => t.scheduleId, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$AndroidNotificationMappingsTableFilterComposer( + $db: $db, + $table: $db.androidNotificationMappings, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return f(composer); + } +} + +class $$NotificationScheduleTableOrderingComposer + extends Composer<_$AppDatabase, $NotificationScheduleTable> { + $$NotificationScheduleTableOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnOrderings get id => $composableBuilder( + column: $table.id, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get generation => $composableBuilder( + column: $table.generation, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get sourceType => $composableBuilder( + column: $table.sourceType, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get sourceId => $composableBuilder( + column: $table.sourceId, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get scheduledAtUtc => $composableBuilder( + column: $table.scheduledAtUtc, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get title => $composableBuilder( + column: $table.title, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get body => $composableBuilder( + column: $table.body, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get sentAtUtc => $composableBuilder( + column: $table.sentAtUtc, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get dismissedAtUtc => $composableBuilder( + column: $table.dismissedAtUtc, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get snoozedUntilUtc => $composableBuilder( + column: $table.snoozedUntilUtc, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get createdAtLocal => $composableBuilder( + column: $table.createdAtLocal, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get updatedAtLocal => $composableBuilder( + column: $table.updatedAtLocal, + builder: (column) => ColumnOrderings(column), + ); + + $$AccountsTableOrderingComposer get accountId { + final $$AccountsTableOrderingComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.accountId, + referencedTable: $db.accounts, + getReferencedColumn: (t) => t.id, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$AccountsTableOrderingComposer( + $db: $db, + $table: $db.accounts, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return composer; + } } -class $$CalendarColorsTableOrderingComposer - extends Composer<_$AppDatabase, $CalendarColorsTable> { - $$CalendarColorsTableOrderingComposer({ +class $$NotificationScheduleTableAnnotationComposer + extends Composer<_$AppDatabase, $NotificationScheduleTable> { + $$NotificationScheduleTableAnnotationComposer({ required super.$db, required super.$table, super.joinBuilder, super.$addJoinBuilderToRootComposer, super.$removeJoinBuilderFromRootComposer, }); - ColumnOrderings get provider => $composableBuilder( - column: $table.provider, - builder: (column) => ColumnOrderings(column), - ); + GeneratedColumn get id => + $composableBuilder(column: $table.id, builder: (column) => column); - ColumnOrderings get colorType => $composableBuilder( - column: $table.colorType, - builder: (column) => ColumnOrderings(column), + GeneratedColumn get generation => $composableBuilder( + column: $table.generation, + builder: (column) => column, ); - ColumnOrderings get colorId => $composableBuilder( - column: $table.colorId, - builder: (column) => ColumnOrderings(column), + GeneratedColumn get sourceType => $composableBuilder( + column: $table.sourceType, + builder: (column) => column, ); - ColumnOrderings get background => $composableBuilder( - column: $table.background, - builder: (column) => ColumnOrderings(column), - ); + GeneratedColumn get sourceId => + $composableBuilder(column: $table.sourceId, builder: (column) => column); - ColumnOrderings get foreground => $composableBuilder( - column: $table.foreground, - builder: (column) => ColumnOrderings(column), + GeneratedColumn get scheduledAtUtc => $composableBuilder( + column: $table.scheduledAtUtc, + builder: (column) => column, ); - ColumnOrderings get rawJson => $composableBuilder( - column: $table.rawJson, - builder: (column) => ColumnOrderings(column), - ); -} + GeneratedColumn get title => + $composableBuilder(column: $table.title, builder: (column) => column); -class $$CalendarColorsTableAnnotationComposer - extends Composer<_$AppDatabase, $CalendarColorsTable> { - $$CalendarColorsTableAnnotationComposer({ - required super.$db, - required super.$table, - super.joinBuilder, - super.$addJoinBuilderToRootComposer, - super.$removeJoinBuilderFromRootComposer, - }); - GeneratedColumn get provider => - $composableBuilder(column: $table.provider, builder: (column) => column); + GeneratedColumn get body => + $composableBuilder(column: $table.body, builder: (column) => column); - GeneratedColumn get colorType => - $composableBuilder(column: $table.colorType, builder: (column) => column); + GeneratedColumn get sentAtUtc => + $composableBuilder(column: $table.sentAtUtc, builder: (column) => column); - GeneratedColumn get colorId => - $composableBuilder(column: $table.colorId, builder: (column) => column); + GeneratedColumn get dismissedAtUtc => $composableBuilder( + column: $table.dismissedAtUtc, + builder: (column) => column, + ); - GeneratedColumn get background => $composableBuilder( - column: $table.background, + GeneratedColumn get snoozedUntilUtc => $composableBuilder( + column: $table.snoozedUntilUtc, builder: (column) => column, ); - GeneratedColumn get foreground => $composableBuilder( - column: $table.foreground, + GeneratedColumn get createdAtLocal => $composableBuilder( + column: $table.createdAtLocal, builder: (column) => column, ); - GeneratedColumn get rawJson => - $composableBuilder(column: $table.rawJson, builder: (column) => column); + GeneratedColumn get updatedAtLocal => $composableBuilder( + column: $table.updatedAtLocal, + builder: (column) => column, + ); + + $$AccountsTableAnnotationComposer get accountId { + final $$AccountsTableAnnotationComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.accountId, + referencedTable: $db.accounts, + getReferencedColumn: (t) => t.id, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$AccountsTableAnnotationComposer( + $db: $db, + $table: $db.accounts, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return composer; + } + + Expression androidNotificationMappingsRefs( + Expression Function( + $$AndroidNotificationMappingsTableAnnotationComposer a, + ) + f, + ) { + final $$AndroidNotificationMappingsTableAnnotationComposer composer = + $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.id, + referencedTable: $db.androidNotificationMappings, + getReferencedColumn: (t) => t.scheduleId, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$AndroidNotificationMappingsTableAnnotationComposer( + $db: $db, + $table: $db.androidNotificationMappings, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return f(composer); + } } -class $$CalendarColorsTableTableManager +class $$NotificationScheduleTableTableManager extends RootTableManager< _$AppDatabase, - $CalendarColorsTable, - CalendarColor, - $$CalendarColorsTableFilterComposer, - $$CalendarColorsTableOrderingComposer, - $$CalendarColorsTableAnnotationComposer, - $$CalendarColorsTableCreateCompanionBuilder, - $$CalendarColorsTableUpdateCompanionBuilder, - ( - CalendarColor, - BaseReferences<_$AppDatabase, $CalendarColorsTable, CalendarColor>, - ), - CalendarColor, - PrefetchHooks Function() + $NotificationScheduleTable, + NotificationScheduleData, + $$NotificationScheduleTableFilterComposer, + $$NotificationScheduleTableOrderingComposer, + $$NotificationScheduleTableAnnotationComposer, + $$NotificationScheduleTableCreateCompanionBuilder, + $$NotificationScheduleTableUpdateCompanionBuilder, + (NotificationScheduleData, $$NotificationScheduleTableReferences), + NotificationScheduleData, + PrefetchHooks Function({ + bool accountId, + bool androidNotificationMappingsRefs, + }) > { - $$CalendarColorsTableTableManager( + $$NotificationScheduleTableTableManager( _$AppDatabase db, - $CalendarColorsTable table, + $NotificationScheduleTable table, ) : super( TableManagerState( db: db, table: table, createFilteringComposer: () => - $$CalendarColorsTableFilterComposer($db: db, $table: table), + $$NotificationScheduleTableFilterComposer($db: db, $table: table), createOrderingComposer: () => - $$CalendarColorsTableOrderingComposer($db: db, $table: table), + $$NotificationScheduleTableOrderingComposer( + $db: db, + $table: table, + ), createComputedFieldComposer: () => - $$CalendarColorsTableAnnotationComposer($db: db, $table: table), + $$NotificationScheduleTableAnnotationComposer( + $db: db, + $table: table, + ), updateCompanionCallback: ({ - Value provider = const Value.absent(), - Value colorType = const Value.absent(), - Value colorId = const Value.absent(), - Value background = const Value.absent(), - Value foreground = const Value.absent(), - Value rawJson = const Value.absent(), + Value id = const Value.absent(), + Value generation = const Value.absent(), + Value accountId = const Value.absent(), + Value sourceType = const Value.absent(), + Value sourceId = const Value.absent(), + Value scheduledAtUtc = const Value.absent(), + Value title = const Value.absent(), + Value body = const Value.absent(), + Value sentAtUtc = const Value.absent(), + Value dismissedAtUtc = const Value.absent(), + Value snoozedUntilUtc = const Value.absent(), + Value createdAtLocal = const Value.absent(), + Value updatedAtLocal = const Value.absent(), Value rowid = const Value.absent(), - }) => CalendarColorsCompanion( - provider: provider, - colorType: colorType, - colorId: colorId, - background: background, - foreground: foreground, - rawJson: rawJson, + }) => NotificationScheduleCompanion( + id: id, + generation: generation, + accountId: accountId, + sourceType: sourceType, + sourceId: sourceId, + scheduledAtUtc: scheduledAtUtc, + title: title, + body: body, + sentAtUtc: sentAtUtc, + dismissedAtUtc: dismissedAtUtc, + snoozedUntilUtc: snoozedUntilUtc, + createdAtLocal: createdAtLocal, + updatedAtLocal: updatedAtLocal, rowid: rowid, ), createCompanionCallback: ({ - required String provider, - required String colorType, - required String colorId, - required String background, - Value foreground = const Value.absent(), - Value rawJson = const Value.absent(), + required String id, + Value generation = const Value.absent(), + required String accountId, + required String sourceType, + required String sourceId, + required int scheduledAtUtc, + required String title, + Value body = const Value.absent(), + Value sentAtUtc = const Value.absent(), + Value dismissedAtUtc = const Value.absent(), + Value snoozedUntilUtc = const Value.absent(), + required int createdAtLocal, + required int updatedAtLocal, Value rowid = const Value.absent(), - }) => CalendarColorsCompanion.insert( - provider: provider, - colorType: colorType, - colorId: colorId, - background: background, - foreground: foreground, - rawJson: rawJson, + }) => NotificationScheduleCompanion.insert( + id: id, + generation: generation, + accountId: accountId, + sourceType: sourceType, + sourceId: sourceId, + scheduledAtUtc: scheduledAtUtc, + title: title, + body: body, + sentAtUtc: sentAtUtc, + dismissedAtUtc: dismissedAtUtc, + snoozedUntilUtc: snoozedUntilUtc, + createdAtLocal: createdAtLocal, + updatedAtLocal: updatedAtLocal, rowid: rowid, ), withReferenceMapper: (p0) => p0 - .map((e) => (e.readTable(table), BaseReferences(db, table, e))) + .map( + (e) => ( + e.readTable< + $NotificationScheduleTable, + NotificationScheduleData + >(table), + $$NotificationScheduleTableReferences(db, table, e), + ), + ) .toList(), - prefetchHooksCallback: null, + prefetchHooksCallback: + ({accountId = false, androidNotificationMappingsRefs = false}) { + return PrefetchHooks( + db: db, + explicitlyWatchedTables: [ + if (androidNotificationMappingsRefs) + db.androidNotificationMappings, + ], + addJoins: + < + T extends TableManagerState< + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic + > + >(state) { + if (accountId) { + state = + state.withJoin( + currentTable: table, + currentColumn: table.accountId, + referencedTable: + $$NotificationScheduleTableReferences + ._accountIdTable(db), + referencedColumn: + $$NotificationScheduleTableReferences + ._accountIdTable(db) + .id, + ) + as T; + } + + return state; + }, + getPrefetchedDataCallback: (items) async { + return [ + if (androidNotificationMappingsRefs) + await $_getPrefetchedData< + NotificationScheduleData, + $NotificationScheduleTable, + AndroidNotificationMapping + >( + currentTable: table, + referencedTable: $$NotificationScheduleTableReferences + ._androidNotificationMappingsRefsTable(db), + managerFromTypedResult: (p0) => + $$NotificationScheduleTableReferences( + db, + table, + p0, + ).androidNotificationMappingsRefs, + referencedItemsForCurrentItem: + (item, referencedItems) => referencedItems.where( + (e) => e.scheduleId == item.id, + ), + typedResults: items, + ), + ]; + }, + ); + }, ), ); } -typedef $$CalendarColorsTableProcessedTableManager = +typedef $$NotificationScheduleTableProcessedTableManager = ProcessedTableManager< _$AppDatabase, - $CalendarColorsTable, - CalendarColor, - $$CalendarColorsTableFilterComposer, - $$CalendarColorsTableOrderingComposer, - $$CalendarColorsTableAnnotationComposer, - $$CalendarColorsTableCreateCompanionBuilder, - $$CalendarColorsTableUpdateCompanionBuilder, - ( - CalendarColor, - BaseReferences<_$AppDatabase, $CalendarColorsTable, CalendarColor>, - ), - CalendarColor, - PrefetchHooks Function() + $NotificationScheduleTable, + NotificationScheduleData, + $$NotificationScheduleTableFilterComposer, + $$NotificationScheduleTableOrderingComposer, + $$NotificationScheduleTableAnnotationComposer, + $$NotificationScheduleTableCreateCompanionBuilder, + $$NotificationScheduleTableUpdateCompanionBuilder, + (NotificationScheduleData, $$NotificationScheduleTableReferences), + NotificationScheduleData, + PrefetchHooks Function({ + bool accountId, + bool androidNotificationMappingsRefs, + }) >; -typedef $$ScheduleItemOverridesTableCreateCompanionBuilder = - ScheduleItemOverridesCompanion Function({ - required String id, - required String accountId, - required String sourceType, - required String sourceId, - required String overrideJson, - required int createdAtLocal, - required int updatedAtLocal, +typedef $$AndroidNotificationMappingsTableCreateCompanionBuilder = + AndroidNotificationMappingsCompanion Function({ + required String scheduleId, + required String generation, + required int platformId, + required int scheduledAtUtc, + Value state, + required int updatedAtUtc, Value rowid, }); -typedef $$ScheduleItemOverridesTableUpdateCompanionBuilder = - ScheduleItemOverridesCompanion Function({ - Value id, - Value accountId, - Value sourceType, - Value sourceId, - Value overrideJson, - Value createdAtLocal, - Value updatedAtLocal, +typedef $$AndroidNotificationMappingsTableUpdateCompanionBuilder = + AndroidNotificationMappingsCompanion Function({ + Value scheduleId, + Value generation, + Value platformId, + Value scheduledAtUtc, + Value state, + Value updatedAtUtc, Value rowid, }); -final class $$ScheduleItemOverridesTableReferences +final class $$AndroidNotificationMappingsTableReferences extends BaseReferences< _$AppDatabase, - $ScheduleItemOverridesTable, - ScheduleItemOverride + $AndroidNotificationMappingsTable, + AndroidNotificationMapping > { - $$ScheduleItemOverridesTableReferences( + $$AndroidNotificationMappingsTableReferences( super.$_db, super.$_table, super.$_typedResult, ); - static $AccountsTable _accountIdTable(_$AppDatabase db) => - db.accounts.createAlias( - $_aliasNameGenerator( - db.scheduleItemOverrides.accountId, - db.accounts.id, - ), + static $NotificationScheduleTable _scheduleIdTable(_$AppDatabase db) => + db.notificationSchedule.createAlias( + 'android_notification_mappings__schedule_id__notification_schedule__id', ); - $$AccountsTableProcessedTableManager get accountId { - final $_column = $_itemColumn('account_id')!; + $$NotificationScheduleTableProcessedTableManager get scheduleId { + final $_column = $_itemColumn('schedule_id')!; - final manager = $$AccountsTableTableManager( + final manager = $$NotificationScheduleTableTableManager( $_db, - $_db.accounts, + $_db.notificationSchedule, ).filter((f) => f.id.sqlEquals($_column)); - final item = $_typedResult.readTableOrNull(_accountIdTable($_db)); + final item = $_typedResult.readTableOrNull(_scheduleIdTable($_db)); if (item == null) return manager; return ProcessedTableManager( manager.$state.copyWith(prefetchedData: [item]), @@ -43733,59 +45542,54 @@ final class $$ScheduleItemOverridesTableReferences } } -class $$ScheduleItemOverridesTableFilterComposer - extends Composer<_$AppDatabase, $ScheduleItemOverridesTable> { - $$ScheduleItemOverridesTableFilterComposer({ +class $$AndroidNotificationMappingsTableFilterComposer + extends Composer<_$AppDatabase, $AndroidNotificationMappingsTable> { + $$AndroidNotificationMappingsTableFilterComposer({ required super.$db, required super.$table, super.joinBuilder, super.$addJoinBuilderToRootComposer, super.$removeJoinBuilderFromRootComposer, }); - ColumnFilters get id => $composableBuilder( - column: $table.id, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get sourceType => $composableBuilder( - column: $table.sourceType, + ColumnFilters get generation => $composableBuilder( + column: $table.generation, builder: (column) => ColumnFilters(column), ); - ColumnFilters get sourceId => $composableBuilder( - column: $table.sourceId, + ColumnFilters get platformId => $composableBuilder( + column: $table.platformId, builder: (column) => ColumnFilters(column), ); - ColumnFilters get overrideJson => $composableBuilder( - column: $table.overrideJson, + ColumnFilters get scheduledAtUtc => $composableBuilder( + column: $table.scheduledAtUtc, builder: (column) => ColumnFilters(column), ); - ColumnFilters get createdAtLocal => $composableBuilder( - column: $table.createdAtLocal, + ColumnFilters get state => $composableBuilder( + column: $table.state, builder: (column) => ColumnFilters(column), ); - ColumnFilters get updatedAtLocal => $composableBuilder( - column: $table.updatedAtLocal, + ColumnFilters get updatedAtUtc => $composableBuilder( + column: $table.updatedAtUtc, builder: (column) => ColumnFilters(column), ); - $$AccountsTableFilterComposer get accountId { - final $$AccountsTableFilterComposer composer = $composerBuilder( + $$NotificationScheduleTableFilterComposer get scheduleId { + final $$NotificationScheduleTableFilterComposer composer = $composerBuilder( composer: this, - getCurrentColumn: (t) => t.accountId, - referencedTable: $db.accounts, + getCurrentColumn: (t) => t.scheduleId, + referencedTable: $db.notificationSchedule, getReferencedColumn: (t) => t.id, builder: ( joinBuilder, { $addJoinBuilderToRootComposer, $removeJoinBuilderFromRootComposer, - }) => $$AccountsTableFilterComposer( + }) => $$NotificationScheduleTableFilterComposer( $db: $db, - $table: $db.accounts, + $table: $db.notificationSchedule, $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, joinBuilder: joinBuilder, $removeJoinBuilderFromRootComposer: @@ -43796,214 +45600,210 @@ class $$ScheduleItemOverridesTableFilterComposer } } -class $$ScheduleItemOverridesTableOrderingComposer - extends Composer<_$AppDatabase, $ScheduleItemOverridesTable> { - $$ScheduleItemOverridesTableOrderingComposer({ +class $$AndroidNotificationMappingsTableOrderingComposer + extends Composer<_$AppDatabase, $AndroidNotificationMappingsTable> { + $$AndroidNotificationMappingsTableOrderingComposer({ required super.$db, required super.$table, super.joinBuilder, super.$addJoinBuilderToRootComposer, super.$removeJoinBuilderFromRootComposer, }); - ColumnOrderings get id => $composableBuilder( - column: $table.id, - builder: (column) => ColumnOrderings(column), - ); - - ColumnOrderings get sourceType => $composableBuilder( - column: $table.sourceType, + ColumnOrderings get generation => $composableBuilder( + column: $table.generation, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get sourceId => $composableBuilder( - column: $table.sourceId, + ColumnOrderings get platformId => $composableBuilder( + column: $table.platformId, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get overrideJson => $composableBuilder( - column: $table.overrideJson, + ColumnOrderings get scheduledAtUtc => $composableBuilder( + column: $table.scheduledAtUtc, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get createdAtLocal => $composableBuilder( - column: $table.createdAtLocal, + ColumnOrderings get state => $composableBuilder( + column: $table.state, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get updatedAtLocal => $composableBuilder( - column: $table.updatedAtLocal, + ColumnOrderings get updatedAtUtc => $composableBuilder( + column: $table.updatedAtUtc, builder: (column) => ColumnOrderings(column), ); - $$AccountsTableOrderingComposer get accountId { - final $$AccountsTableOrderingComposer composer = $composerBuilder( - composer: this, - getCurrentColumn: (t) => t.accountId, - referencedTable: $db.accounts, - getReferencedColumn: (t) => t.id, - builder: - ( - joinBuilder, { - $addJoinBuilderToRootComposer, - $removeJoinBuilderFromRootComposer, - }) => $$AccountsTableOrderingComposer( - $db: $db, - $table: $db.accounts, - $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, - joinBuilder: joinBuilder, - $removeJoinBuilderFromRootComposer: + $$NotificationScheduleTableOrderingComposer get scheduleId { + final $$NotificationScheduleTableOrderingComposer composer = + $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.scheduleId, + referencedTable: $db.notificationSchedule, + getReferencedColumn: (t) => t.id, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, $removeJoinBuilderFromRootComposer, - ), - ); + }) => $$NotificationScheduleTableOrderingComposer( + $db: $db, + $table: $db.notificationSchedule, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); return composer; } } -class $$ScheduleItemOverridesTableAnnotationComposer - extends Composer<_$AppDatabase, $ScheduleItemOverridesTable> { - $$ScheduleItemOverridesTableAnnotationComposer({ +class $$AndroidNotificationMappingsTableAnnotationComposer + extends Composer<_$AppDatabase, $AndroidNotificationMappingsTable> { + $$AndroidNotificationMappingsTableAnnotationComposer({ required super.$db, required super.$table, super.joinBuilder, super.$addJoinBuilderToRootComposer, super.$removeJoinBuilderFromRootComposer, }); - GeneratedColumn get id => - $composableBuilder(column: $table.id, builder: (column) => column); - - GeneratedColumn get sourceType => $composableBuilder( - column: $table.sourceType, + GeneratedColumn get generation => $composableBuilder( + column: $table.generation, builder: (column) => column, ); - GeneratedColumn get sourceId => - $composableBuilder(column: $table.sourceId, builder: (column) => column); - - GeneratedColumn get overrideJson => $composableBuilder( - column: $table.overrideJson, + GeneratedColumn get platformId => $composableBuilder( + column: $table.platformId, builder: (column) => column, ); - GeneratedColumn get createdAtLocal => $composableBuilder( - column: $table.createdAtLocal, + GeneratedColumn get scheduledAtUtc => $composableBuilder( + column: $table.scheduledAtUtc, builder: (column) => column, ); - GeneratedColumn get updatedAtLocal => $composableBuilder( - column: $table.updatedAtLocal, + GeneratedColumn get state => + $composableBuilder(column: $table.state, builder: (column) => column); + + GeneratedColumn get updatedAtUtc => $composableBuilder( + column: $table.updatedAtUtc, builder: (column) => column, ); - $$AccountsTableAnnotationComposer get accountId { - final $$AccountsTableAnnotationComposer composer = $composerBuilder( - composer: this, - getCurrentColumn: (t) => t.accountId, - referencedTable: $db.accounts, - getReferencedColumn: (t) => t.id, - builder: - ( - joinBuilder, { - $addJoinBuilderToRootComposer, - $removeJoinBuilderFromRootComposer, - }) => $$AccountsTableAnnotationComposer( - $db: $db, - $table: $db.accounts, - $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, - joinBuilder: joinBuilder, - $removeJoinBuilderFromRootComposer: + $$NotificationScheduleTableAnnotationComposer get scheduleId { + final $$NotificationScheduleTableAnnotationComposer composer = + $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.scheduleId, + referencedTable: $db.notificationSchedule, + getReferencedColumn: (t) => t.id, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, $removeJoinBuilderFromRootComposer, - ), - ); + }) => $$NotificationScheduleTableAnnotationComposer( + $db: $db, + $table: $db.notificationSchedule, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); return composer; } } -class $$ScheduleItemOverridesTableTableManager +class $$AndroidNotificationMappingsTableTableManager extends RootTableManager< _$AppDatabase, - $ScheduleItemOverridesTable, - ScheduleItemOverride, - $$ScheduleItemOverridesTableFilterComposer, - $$ScheduleItemOverridesTableOrderingComposer, - $$ScheduleItemOverridesTableAnnotationComposer, - $$ScheduleItemOverridesTableCreateCompanionBuilder, - $$ScheduleItemOverridesTableUpdateCompanionBuilder, - (ScheduleItemOverride, $$ScheduleItemOverridesTableReferences), - ScheduleItemOverride, - PrefetchHooks Function({bool accountId}) + $AndroidNotificationMappingsTable, + AndroidNotificationMapping, + $$AndroidNotificationMappingsTableFilterComposer, + $$AndroidNotificationMappingsTableOrderingComposer, + $$AndroidNotificationMappingsTableAnnotationComposer, + $$AndroidNotificationMappingsTableCreateCompanionBuilder, + $$AndroidNotificationMappingsTableUpdateCompanionBuilder, + ( + AndroidNotificationMapping, + $$AndroidNotificationMappingsTableReferences, + ), + AndroidNotificationMapping, + PrefetchHooks Function({bool scheduleId}) > { - $$ScheduleItemOverridesTableTableManager( + $$AndroidNotificationMappingsTableTableManager( _$AppDatabase db, - $ScheduleItemOverridesTable table, + $AndroidNotificationMappingsTable table, ) : super( TableManagerState( db: db, table: table, createFilteringComposer: () => - $$ScheduleItemOverridesTableFilterComposer( + $$AndroidNotificationMappingsTableFilterComposer( $db: db, $table: table, ), createOrderingComposer: () => - $$ScheduleItemOverridesTableOrderingComposer( + $$AndroidNotificationMappingsTableOrderingComposer( $db: db, $table: table, ), createComputedFieldComposer: () => - $$ScheduleItemOverridesTableAnnotationComposer( + $$AndroidNotificationMappingsTableAnnotationComposer( $db: db, $table: table, ), updateCompanionCallback: ({ - Value id = const Value.absent(), - Value accountId = const Value.absent(), - Value sourceType = const Value.absent(), - Value sourceId = const Value.absent(), - Value overrideJson = const Value.absent(), - Value createdAtLocal = const Value.absent(), - Value updatedAtLocal = const Value.absent(), + Value scheduleId = const Value.absent(), + Value generation = const Value.absent(), + Value platformId = const Value.absent(), + Value scheduledAtUtc = const Value.absent(), + Value state = const Value.absent(), + Value updatedAtUtc = const Value.absent(), Value rowid = const Value.absent(), - }) => ScheduleItemOverridesCompanion( - id: id, - accountId: accountId, - sourceType: sourceType, - sourceId: sourceId, - overrideJson: overrideJson, - createdAtLocal: createdAtLocal, - updatedAtLocal: updatedAtLocal, + }) => AndroidNotificationMappingsCompanion( + scheduleId: scheduleId, + generation: generation, + platformId: platformId, + scheduledAtUtc: scheduledAtUtc, + state: state, + updatedAtUtc: updatedAtUtc, rowid: rowid, ), createCompanionCallback: ({ - required String id, - required String accountId, - required String sourceType, - required String sourceId, - required String overrideJson, - required int createdAtLocal, - required int updatedAtLocal, + required String scheduleId, + required String generation, + required int platformId, + required int scheduledAtUtc, + Value state = const Value.absent(), + required int updatedAtUtc, Value rowid = const Value.absent(), - }) => ScheduleItemOverridesCompanion.insert( - id: id, - accountId: accountId, - sourceType: sourceType, - sourceId: sourceId, - overrideJson: overrideJson, - createdAtLocal: createdAtLocal, - updatedAtLocal: updatedAtLocal, + }) => AndroidNotificationMappingsCompanion.insert( + scheduleId: scheduleId, + generation: generation, + platformId: platformId, + scheduledAtUtc: scheduledAtUtc, + state: state, + updatedAtUtc: updatedAtUtc, rowid: rowid, ), withReferenceMapper: (p0) => p0 .map( (e) => ( - e.readTable(table), - $$ScheduleItemOverridesTableReferences(db, table, e), + e.readTable< + $AndroidNotificationMappingsTable, + AndroidNotificationMapping + >(table), + $$AndroidNotificationMappingsTableReferences(db, table, e), ), ) .toList(), - prefetchHooksCallback: ({accountId = false}) { + prefetchHooksCallback: ({scheduleId = false}) { return PrefetchHooks( db: db, explicitlyWatchedTables: [], @@ -44023,17 +45823,17 @@ class $$ScheduleItemOverridesTableTableManager dynamic > >(state) { - if (accountId) { + if (scheduleId) { state = state.withJoin( currentTable: table, - currentColumn: table.accountId, + currentColumn: table.scheduleId, referencedTable: - $$ScheduleItemOverridesTableReferences - ._accountIdTable(db), + $$AndroidNotificationMappingsTableReferences + ._scheduleIdTable(db), referencedColumn: - $$ScheduleItemOverridesTableReferences - ._accountIdTable(db) + $$AndroidNotificationMappingsTableReferences + ._scheduleIdTable(db) .id, ) as T; @@ -44050,114 +45850,67 @@ class $$ScheduleItemOverridesTableTableManager ); } -typedef $$ScheduleItemOverridesTableProcessedTableManager = +typedef $$AndroidNotificationMappingsTableProcessedTableManager = ProcessedTableManager< _$AppDatabase, - $ScheduleItemOverridesTable, - ScheduleItemOverride, - $$ScheduleItemOverridesTableFilterComposer, - $$ScheduleItemOverridesTableOrderingComposer, - $$ScheduleItemOverridesTableAnnotationComposer, - $$ScheduleItemOverridesTableCreateCompanionBuilder, - $$ScheduleItemOverridesTableUpdateCompanionBuilder, - (ScheduleItemOverride, $$ScheduleItemOverridesTableReferences), - ScheduleItemOverride, - PrefetchHooks Function({bool accountId}) + $AndroidNotificationMappingsTable, + AndroidNotificationMapping, + $$AndroidNotificationMappingsTableFilterComposer, + $$AndroidNotificationMappingsTableOrderingComposer, + $$AndroidNotificationMappingsTableAnnotationComposer, + $$AndroidNotificationMappingsTableCreateCompanionBuilder, + $$AndroidNotificationMappingsTableUpdateCompanionBuilder, + ( + AndroidNotificationMapping, + $$AndroidNotificationMappingsTableReferences, + ), + AndroidNotificationMapping, + PrefetchHooks Function({bool scheduleId}) >; -typedef $$NotificationScheduleTableCreateCompanionBuilder = - NotificationScheduleCompanion Function({ - required String id, - Value generation, - required String accountId, - required String sourceType, - required String sourceId, +typedef $$AndroidDailySummarySchedulesTableCreateCompanionBuilder = + AndroidDailySummarySchedulesCompanion Function({ + required String localDate, + required int platformId, + required String generation, required int scheduledAtUtc, - required String title, - Value body, - Value sentAtUtc, - Value dismissedAtUtc, - Value snoozedUntilUtc, - required int createdAtLocal, - required int updatedAtLocal, + required int taskCount, + Value state, + required int updatedAtUtc, Value rowid, }); -typedef $$NotificationScheduleTableUpdateCompanionBuilder = - NotificationScheduleCompanion Function({ - Value id, +typedef $$AndroidDailySummarySchedulesTableUpdateCompanionBuilder = + AndroidDailySummarySchedulesCompanion Function({ + Value localDate, + Value platformId, Value generation, - Value accountId, - Value sourceType, - Value sourceId, Value scheduledAtUtc, - Value title, - Value body, - Value sentAtUtc, - Value dismissedAtUtc, - Value snoozedUntilUtc, - Value createdAtLocal, - Value updatedAtLocal, + Value taskCount, + Value state, + Value updatedAtUtc, Value rowid, }); -final class $$NotificationScheduleTableReferences - extends - BaseReferences< - _$AppDatabase, - $NotificationScheduleTable, - NotificationScheduleData - > { - $$NotificationScheduleTableReferences( - super.$_db, - super.$_table, - super.$_typedResult, - ); - - static $AccountsTable _accountIdTable(_$AppDatabase db) => - db.accounts.createAlias( - $_aliasNameGenerator(db.notificationSchedule.accountId, db.accounts.id), - ); - - $$AccountsTableProcessedTableManager get accountId { - final $_column = $_itemColumn('account_id')!; - - final manager = $$AccountsTableTableManager( - $_db, - $_db.accounts, - ).filter((f) => f.id.sqlEquals($_column)); - final item = $_typedResult.readTableOrNull(_accountIdTable($_db)); - if (item == null) return manager; - return ProcessedTableManager( - manager.$state.copyWith(prefetchedData: [item]), - ); - } -} - -class $$NotificationScheduleTableFilterComposer - extends Composer<_$AppDatabase, $NotificationScheduleTable> { - $$NotificationScheduleTableFilterComposer({ +class $$AndroidDailySummarySchedulesTableFilterComposer + extends Composer<_$AppDatabase, $AndroidDailySummarySchedulesTable> { + $$AndroidDailySummarySchedulesTableFilterComposer({ required super.$db, required super.$table, super.joinBuilder, super.$addJoinBuilderToRootComposer, super.$removeJoinBuilderFromRootComposer, }); - ColumnFilters get id => $composableBuilder( - column: $table.id, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get generation => $composableBuilder( - column: $table.generation, + ColumnFilters get localDate => $composableBuilder( + column: $table.localDate, builder: (column) => ColumnFilters(column), ); - ColumnFilters get sourceType => $composableBuilder( - column: $table.sourceType, + ColumnFilters get platformId => $composableBuilder( + column: $table.platformId, builder: (column) => ColumnFilters(column), ); - ColumnFilters get sourceId => $composableBuilder( - column: $table.sourceId, + ColumnFilters get generation => $composableBuilder( + column: $table.generation, builder: (column) => ColumnFilters(column), ); @@ -44166,407 +45919,230 @@ class $$NotificationScheduleTableFilterComposer builder: (column) => ColumnFilters(column), ); - ColumnFilters get title => $composableBuilder( - column: $table.title, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get body => $composableBuilder( - column: $table.body, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get sentAtUtc => $composableBuilder( - column: $table.sentAtUtc, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get dismissedAtUtc => $composableBuilder( - column: $table.dismissedAtUtc, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get snoozedUntilUtc => $composableBuilder( - column: $table.snoozedUntilUtc, - builder: (column) => ColumnFilters(column), - ); - - ColumnFilters get createdAtLocal => $composableBuilder( - column: $table.createdAtLocal, + ColumnFilters get taskCount => $composableBuilder( + column: $table.taskCount, builder: (column) => ColumnFilters(column), ); - ColumnFilters get updatedAtLocal => $composableBuilder( - column: $table.updatedAtLocal, + ColumnFilters get state => $composableBuilder( + column: $table.state, builder: (column) => ColumnFilters(column), ); - $$AccountsTableFilterComposer get accountId { - final $$AccountsTableFilterComposer composer = $composerBuilder( - composer: this, - getCurrentColumn: (t) => t.accountId, - referencedTable: $db.accounts, - getReferencedColumn: (t) => t.id, - builder: - ( - joinBuilder, { - $addJoinBuilderToRootComposer, - $removeJoinBuilderFromRootComposer, - }) => $$AccountsTableFilterComposer( - $db: $db, - $table: $db.accounts, - $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, - joinBuilder: joinBuilder, - $removeJoinBuilderFromRootComposer: - $removeJoinBuilderFromRootComposer, - ), - ); - return composer; - } -} - -class $$NotificationScheduleTableOrderingComposer - extends Composer<_$AppDatabase, $NotificationScheduleTable> { - $$NotificationScheduleTableOrderingComposer({ - required super.$db, - required super.$table, - super.joinBuilder, - super.$addJoinBuilderToRootComposer, - super.$removeJoinBuilderFromRootComposer, - }); - ColumnOrderings get id => $composableBuilder( - column: $table.id, - builder: (column) => ColumnOrderings(column), - ); - - ColumnOrderings get generation => $composableBuilder( - column: $table.generation, - builder: (column) => ColumnOrderings(column), - ); - - ColumnOrderings get sourceType => $composableBuilder( - column: $table.sourceType, - builder: (column) => ColumnOrderings(column), - ); - - ColumnOrderings get sourceId => $composableBuilder( - column: $table.sourceId, - builder: (column) => ColumnOrderings(column), - ); - - ColumnOrderings get scheduledAtUtc => $composableBuilder( - column: $table.scheduledAtUtc, - builder: (column) => ColumnOrderings(column), + ColumnFilters get updatedAtUtc => $composableBuilder( + column: $table.updatedAtUtc, + builder: (column) => ColumnFilters(column), ); +} - ColumnOrderings get title => $composableBuilder( - column: $table.title, +class $$AndroidDailySummarySchedulesTableOrderingComposer + extends Composer<_$AppDatabase, $AndroidDailySummarySchedulesTable> { + $$AndroidDailySummarySchedulesTableOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnOrderings get localDate => $composableBuilder( + column: $table.localDate, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get body => $composableBuilder( - column: $table.body, + ColumnOrderings get platformId => $composableBuilder( + column: $table.platformId, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get sentAtUtc => $composableBuilder( - column: $table.sentAtUtc, + ColumnOrderings get generation => $composableBuilder( + column: $table.generation, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get dismissedAtUtc => $composableBuilder( - column: $table.dismissedAtUtc, + ColumnOrderings get scheduledAtUtc => $composableBuilder( + column: $table.scheduledAtUtc, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get snoozedUntilUtc => $composableBuilder( - column: $table.snoozedUntilUtc, + ColumnOrderings get taskCount => $composableBuilder( + column: $table.taskCount, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get createdAtLocal => $composableBuilder( - column: $table.createdAtLocal, + ColumnOrderings get state => $composableBuilder( + column: $table.state, builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get updatedAtLocal => $composableBuilder( - column: $table.updatedAtLocal, + ColumnOrderings get updatedAtUtc => $composableBuilder( + column: $table.updatedAtUtc, builder: (column) => ColumnOrderings(column), ); - - $$AccountsTableOrderingComposer get accountId { - final $$AccountsTableOrderingComposer composer = $composerBuilder( - composer: this, - getCurrentColumn: (t) => t.accountId, - referencedTable: $db.accounts, - getReferencedColumn: (t) => t.id, - builder: - ( - joinBuilder, { - $addJoinBuilderToRootComposer, - $removeJoinBuilderFromRootComposer, - }) => $$AccountsTableOrderingComposer( - $db: $db, - $table: $db.accounts, - $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, - joinBuilder: joinBuilder, - $removeJoinBuilderFromRootComposer: - $removeJoinBuilderFromRootComposer, - ), - ); - return composer; - } } -class $$NotificationScheduleTableAnnotationComposer - extends Composer<_$AppDatabase, $NotificationScheduleTable> { - $$NotificationScheduleTableAnnotationComposer({ +class $$AndroidDailySummarySchedulesTableAnnotationComposer + extends Composer<_$AppDatabase, $AndroidDailySummarySchedulesTable> { + $$AndroidDailySummarySchedulesTableAnnotationComposer({ required super.$db, required super.$table, super.joinBuilder, super.$addJoinBuilderToRootComposer, super.$removeJoinBuilderFromRootComposer, }); - GeneratedColumn get id => - $composableBuilder(column: $table.id, builder: (column) => column); + GeneratedColumn get localDate => + $composableBuilder(column: $table.localDate, builder: (column) => column); - GeneratedColumn get generation => $composableBuilder( - column: $table.generation, + GeneratedColumn get platformId => $composableBuilder( + column: $table.platformId, builder: (column) => column, ); - GeneratedColumn get sourceType => $composableBuilder( - column: $table.sourceType, + GeneratedColumn get generation => $composableBuilder( + column: $table.generation, builder: (column) => column, ); - GeneratedColumn get sourceId => - $composableBuilder(column: $table.sourceId, builder: (column) => column); - GeneratedColumn get scheduledAtUtc => $composableBuilder( column: $table.scheduledAtUtc, builder: (column) => column, ); - GeneratedColumn get title => - $composableBuilder(column: $table.title, builder: (column) => column); - - GeneratedColumn get body => - $composableBuilder(column: $table.body, builder: (column) => column); - - GeneratedColumn get sentAtUtc => - $composableBuilder(column: $table.sentAtUtc, builder: (column) => column); - - GeneratedColumn get dismissedAtUtc => $composableBuilder( - column: $table.dismissedAtUtc, - builder: (column) => column, - ); - - GeneratedColumn get snoozedUntilUtc => $composableBuilder( - column: $table.snoozedUntilUtc, - builder: (column) => column, - ); + GeneratedColumn get taskCount => + $composableBuilder(column: $table.taskCount, builder: (column) => column); - GeneratedColumn get createdAtLocal => $composableBuilder( - column: $table.createdAtLocal, - builder: (column) => column, - ); + GeneratedColumn get state => + $composableBuilder(column: $table.state, builder: (column) => column); - GeneratedColumn get updatedAtLocal => $composableBuilder( - column: $table.updatedAtLocal, + GeneratedColumn get updatedAtUtc => $composableBuilder( + column: $table.updatedAtUtc, builder: (column) => column, ); - - $$AccountsTableAnnotationComposer get accountId { - final $$AccountsTableAnnotationComposer composer = $composerBuilder( - composer: this, - getCurrentColumn: (t) => t.accountId, - referencedTable: $db.accounts, - getReferencedColumn: (t) => t.id, - builder: - ( - joinBuilder, { - $addJoinBuilderToRootComposer, - $removeJoinBuilderFromRootComposer, - }) => $$AccountsTableAnnotationComposer( - $db: $db, - $table: $db.accounts, - $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, - joinBuilder: joinBuilder, - $removeJoinBuilderFromRootComposer: - $removeJoinBuilderFromRootComposer, - ), - ); - return composer; - } } -class $$NotificationScheduleTableTableManager +class $$AndroidDailySummarySchedulesTableTableManager extends RootTableManager< _$AppDatabase, - $NotificationScheduleTable, - NotificationScheduleData, - $$NotificationScheduleTableFilterComposer, - $$NotificationScheduleTableOrderingComposer, - $$NotificationScheduleTableAnnotationComposer, - $$NotificationScheduleTableCreateCompanionBuilder, - $$NotificationScheduleTableUpdateCompanionBuilder, - (NotificationScheduleData, $$NotificationScheduleTableReferences), - NotificationScheduleData, - PrefetchHooks Function({bool accountId}) + $AndroidDailySummarySchedulesTable, + AndroidDailySummarySchedule, + $$AndroidDailySummarySchedulesTableFilterComposer, + $$AndroidDailySummarySchedulesTableOrderingComposer, + $$AndroidDailySummarySchedulesTableAnnotationComposer, + $$AndroidDailySummarySchedulesTableCreateCompanionBuilder, + $$AndroidDailySummarySchedulesTableUpdateCompanionBuilder, + ( + AndroidDailySummarySchedule, + BaseReferences< + _$AppDatabase, + $AndroidDailySummarySchedulesTable, + AndroidDailySummarySchedule + >, + ), + AndroidDailySummarySchedule, + PrefetchHooks Function() > { - $$NotificationScheduleTableTableManager( + $$AndroidDailySummarySchedulesTableTableManager( _$AppDatabase db, - $NotificationScheduleTable table, + $AndroidDailySummarySchedulesTable table, ) : super( TableManagerState( db: db, table: table, createFilteringComposer: () => - $$NotificationScheduleTableFilterComposer($db: db, $table: table), + $$AndroidDailySummarySchedulesTableFilterComposer( + $db: db, + $table: table, + ), createOrderingComposer: () => - $$NotificationScheduleTableOrderingComposer( + $$AndroidDailySummarySchedulesTableOrderingComposer( $db: db, $table: table, ), createComputedFieldComposer: () => - $$NotificationScheduleTableAnnotationComposer( + $$AndroidDailySummarySchedulesTableAnnotationComposer( $db: db, $table: table, ), updateCompanionCallback: ({ - Value id = const Value.absent(), + Value localDate = const Value.absent(), + Value platformId = const Value.absent(), Value generation = const Value.absent(), - Value accountId = const Value.absent(), - Value sourceType = const Value.absent(), - Value sourceId = const Value.absent(), Value scheduledAtUtc = const Value.absent(), - Value title = const Value.absent(), - Value body = const Value.absent(), - Value sentAtUtc = const Value.absent(), - Value dismissedAtUtc = const Value.absent(), - Value snoozedUntilUtc = const Value.absent(), - Value createdAtLocal = const Value.absent(), - Value updatedAtLocal = const Value.absent(), + Value taskCount = const Value.absent(), + Value state = const Value.absent(), + Value updatedAtUtc = const Value.absent(), Value rowid = const Value.absent(), - }) => NotificationScheduleCompanion( - id: id, + }) => AndroidDailySummarySchedulesCompanion( + localDate: localDate, + platformId: platformId, generation: generation, - accountId: accountId, - sourceType: sourceType, - sourceId: sourceId, scheduledAtUtc: scheduledAtUtc, - title: title, - body: body, - sentAtUtc: sentAtUtc, - dismissedAtUtc: dismissedAtUtc, - snoozedUntilUtc: snoozedUntilUtc, - createdAtLocal: createdAtLocal, - updatedAtLocal: updatedAtLocal, + taskCount: taskCount, + state: state, + updatedAtUtc: updatedAtUtc, rowid: rowid, ), createCompanionCallback: ({ - required String id, - Value generation = const Value.absent(), - required String accountId, - required String sourceType, - required String sourceId, + required String localDate, + required int platformId, + required String generation, required int scheduledAtUtc, - required String title, - Value body = const Value.absent(), - Value sentAtUtc = const Value.absent(), - Value dismissedAtUtc = const Value.absent(), - Value snoozedUntilUtc = const Value.absent(), - required int createdAtLocal, - required int updatedAtLocal, + required int taskCount, + Value state = const Value.absent(), + required int updatedAtUtc, Value rowid = const Value.absent(), - }) => NotificationScheduleCompanion.insert( - id: id, + }) => AndroidDailySummarySchedulesCompanion.insert( + localDate: localDate, + platformId: platformId, generation: generation, - accountId: accountId, - sourceType: sourceType, - sourceId: sourceId, scheduledAtUtc: scheduledAtUtc, - title: title, - body: body, - sentAtUtc: sentAtUtc, - dismissedAtUtc: dismissedAtUtc, - snoozedUntilUtc: snoozedUntilUtc, - createdAtLocal: createdAtLocal, - updatedAtLocal: updatedAtLocal, + taskCount: taskCount, + state: state, + updatedAtUtc: updatedAtUtc, rowid: rowid, ), withReferenceMapper: (p0) => p0 .map( (e) => ( - e.readTable(table), - $$NotificationScheduleTableReferences(db, table, e), + e.readTable< + $AndroidDailySummarySchedulesTable, + AndroidDailySummarySchedule + >(table), + BaseReferences< + _$AppDatabase, + $AndroidDailySummarySchedulesTable, + AndroidDailySummarySchedule + >(db, table, e), ), ) .toList(), - prefetchHooksCallback: ({accountId = false}) { - return PrefetchHooks( - db: db, - explicitlyWatchedTables: [], - addJoins: - < - T extends TableManagerState< - dynamic, - dynamic, - dynamic, - dynamic, - dynamic, - dynamic, - dynamic, - dynamic, - dynamic, - dynamic, - dynamic - > - >(state) { - if (accountId) { - state = - state.withJoin( - currentTable: table, - currentColumn: table.accountId, - referencedTable: - $$NotificationScheduleTableReferences - ._accountIdTable(db), - referencedColumn: - $$NotificationScheduleTableReferences - ._accountIdTable(db) - .id, - ) - as T; - } - - return state; - }, - getPrefetchedDataCallback: (items) async { - return []; - }, - ); - }, + prefetchHooksCallback: null, ), ); } -typedef $$NotificationScheduleTableProcessedTableManager = +typedef $$AndroidDailySummarySchedulesTableProcessedTableManager = ProcessedTableManager< _$AppDatabase, - $NotificationScheduleTable, - NotificationScheduleData, - $$NotificationScheduleTableFilterComposer, - $$NotificationScheduleTableOrderingComposer, - $$NotificationScheduleTableAnnotationComposer, - $$NotificationScheduleTableCreateCompanionBuilder, - $$NotificationScheduleTableUpdateCompanionBuilder, - (NotificationScheduleData, $$NotificationScheduleTableReferences), - NotificationScheduleData, - PrefetchHooks Function({bool accountId}) + $AndroidDailySummarySchedulesTable, + AndroidDailySummarySchedule, + $$AndroidDailySummarySchedulesTableFilterComposer, + $$AndroidDailySummarySchedulesTableOrderingComposer, + $$AndroidDailySummarySchedulesTableAnnotationComposer, + $$AndroidDailySummarySchedulesTableCreateCompanionBuilder, + $$AndroidDailySummarySchedulesTableUpdateCompanionBuilder, + ( + AndroidDailySummarySchedule, + BaseReferences< + _$AppDatabase, + $AndroidDailySummarySchedulesTable, + AndroidDailySummarySchedule + >, + ), + AndroidDailySummarySchedule, + PrefetchHooks Function() >; typedef $$LocationResolutionsTableCreateCompanionBuilder = LocationResolutionsCompanion Function({ @@ -44611,9 +46187,7 @@ final class $$LocationResolutionsTableReferences ); static $AccountsTable _accountIdTable(_$AppDatabase db) => - db.accounts.createAlias( - $_aliasNameGenerator(db.locationResolutions.accountId, db.accounts.id), - ); + db.accounts.createAlias('location_resolutions__account_id__accounts__id'); $$AccountsTableProcessedTableManager get accountId { final $_column = $_itemColumn('account_id')!; @@ -44939,7 +46513,9 @@ class $$LocationResolutionsTableTableManager withReferenceMapper: (p0) => p0 .map( (e) => ( - e.readTable(table), + e.readTable<$LocationResolutionsTable, LocationResolution>( + table, + ), $$LocationResolutionsTableReferences(db, table, e), ), ) @@ -45055,6 +46631,18 @@ class $AppDatabaseManager { $$ScheduleItemOverridesTableTableManager(_db, _db.scheduleItemOverrides); $$NotificationScheduleTableTableManager get notificationSchedule => $$NotificationScheduleTableTableManager(_db, _db.notificationSchedule); + $$AndroidNotificationMappingsTableTableManager + get androidNotificationMappings => + $$AndroidNotificationMappingsTableTableManager( + _db, + _db.androidNotificationMappings, + ); + $$AndroidDailySummarySchedulesTableTableManager + get androidDailySummarySchedules => + $$AndroidDailySummarySchedulesTableTableManager( + _db, + _db.androidDailySummarySchedules, + ); $$LocationResolutionsTableTableManager get locationResolutions => $$LocationResolutionsTableTableManager(_db, _db.locationResolutions); } diff --git a/lib/src/db/migrations.dart b/lib/src/db/migrations.dart index e0cbf7b5..c7da87c2 100644 --- a/lib/src/db/migrations.dart +++ b/lib/src/db/migrations.dart @@ -3,7 +3,7 @@ import 'package:drift/drift.dart'; import 'app_database.dart'; import 'location_migration.dart'; -const latestSchemaVersion = 16; +const latestSchemaVersion = 18; /// A recoverable, non-secret diagnostic raised when an on-disk schema cannot /// be migrated without guessing remote identity or losing synchronized data. @@ -84,6 +84,12 @@ MigrationStrategy busyMaxMigrationStrategy(AppDatabase database) { database.notificationSchedule.generation, ); } + if (from < 17) { + await migrator.createTable(database.androidNotificationMappings); + } + if (from < 18) { + await migrator.createTable(database.androidDailySummarySchedules); + } await _createIndexes(database); await createLocationLifecycleTriggers(database); await _verifyForeignKeys(database); diff --git a/lib/src/db/tables.dart b/lib/src/db/tables.dart index 891bbdf3..d212e0c8 100644 --- a/lib/src/db/tables.dart +++ b/lib/src/db/tables.dart @@ -688,6 +688,39 @@ class NotificationSchedule extends Table { Set> get primaryKey => {id}; } +/// Durable Android alarm identity. This maps a mutable logical reminder to a +/// collision-free platform integer without duplicating calendar/task data. +class AndroidNotificationMappings extends Table { + TextColumn get scheduleId => text().references( + NotificationSchedule, + #id, + onDelete: KeyAction.cascade, + )(); + TextColumn get generation => text()(); + IntColumn get platformId => integer().unique()(); + IntColumn get scheduledAtUtc => integer()(); + TextColumn get state => text().withDefault(const Constant('scheduled'))(); + IntColumn get updatedAtUtc => integer()(); + + @override + Set> get primaryKey => {scheduleId}; +} + +/// Registration bookkeeping for the optional once-per-local-day task summary. +/// This records what BusyMax asked Android to schedule, not a delivery claim. +class AndroidDailySummarySchedules extends Table { + TextColumn get localDate => text()(); + IntColumn get platformId => integer()(); + TextColumn get generation => text()(); + IntColumn get scheduledAtUtc => integer()(); + IntColumn get taskCount => integer()(); + TextColumn get state => text().withDefault(const Constant('scheduled'))(); + IntColumn get updatedAtUtc => integer()(); + + @override + Set> get primaryKey => {localDate}; +} + class SyncRuns extends Table { TextColumn get id => text()(); TextColumn get accountId => diff --git a/lib/src/features/auth/data/auth_repository.dart b/lib/src/features/auth/data/auth_repository.dart index 194802dd..00b3dd4d 100644 --- a/lib/src/features/auth/data/auth_repository.dart +++ b/lib/src/features/auth/data/auth_repository.dart @@ -90,7 +90,7 @@ class AuthRepository { required OAuthGateway oAuth, required AppDatabase database, AccountsRepository? accountsRepository, - MicrosoftOAuthService? microsoftOAuth, + MicrosoftOAuthGateway? microsoftOAuth, DateTime Function()? nowUtc, }) : _oAuth = oAuth, _database = database, @@ -102,7 +102,7 @@ class AuthRepository { final OAuthGateway _oAuth; final AppDatabase _database; final AccountsRepository _accountsRepository; - final MicrosoftOAuthService? _microsoftOAuth; + final MicrosoftOAuthGateway? _microsoftOAuth; final RedactingLogger _logger = RedactingLogger(Logger('AuthRepository')); Future loadSession() async { @@ -144,7 +144,7 @@ class AuthRepository { 'Microsoft sign-in is not available.', ); } - final result = await microsoftOAuth.signIn(); + final result = await microsoftOAuth.signInWithMicrosoft(); if (!_hasRequiredMicrosoftScopes(result.tokenSet)) { await _bestEffortInsufficientScopeCleanup( 'Microsoft', diff --git a/lib/src/features/maps/application/external_location_launcher.dart b/lib/src/features/maps/application/external_location_launcher.dart index 1faa6587..a61049ed 100644 --- a/lib/src/features/maps/application/external_location_launcher.dart +++ b/lib/src/features/maps/application/external_location_launcher.dart @@ -23,7 +23,7 @@ Future _launchLinuxExternalUri( false; } -enum ExternalLocationPlatform { linux, windows } +enum ExternalLocationPlatform { linux, windows, android } enum ExternalLocationLaunchResult { opened, @@ -105,6 +105,13 @@ final class ExternalLocationLauncher { } } + if (currentPlatform == ExternalLocationPlatform.android) { + final nativeUri = androidLocationUri(destination); + if (await _launch(nativeUri, useLinuxLauncher: false)) { + return ExternalLocationLaunchResult.opened; + } + } + final browserUri = googleMapsSearchUri(destination.searchQuery); if (browserUri.toString().length > googleMapsUrlLimit) { return ExternalLocationLaunchResult.browserUrlTooLong; @@ -131,8 +138,15 @@ final class ExternalLocationLauncher { ExternalLocationPlatform currentExternalLocationPlatform() => Platform.isLinux ? ExternalLocationPlatform.linux + : Platform.isAndroid + ? ExternalLocationPlatform.android : ExternalLocationPlatform.windows; +Uri androidLocationUri(ExternalLocationDestination destination) { + final query = destination.searchQuery; + return Uri.parse('geo:0,0?q=${Uri.encodeComponent(query)}'); +} + Uri linuxLocationUri(ExternalLocationDestination destination) { return switch (destination.kind) { ExternalLocationDestinationKind.coordinates => Uri( diff --git a/lib/src/features/notifications/notification_reconciler.dart b/lib/src/features/notifications/notification_reconciler.dart new file mode 100644 index 00000000..0a181aef --- /dev/null +++ b/lib/src/features/notifications/notification_reconciler.dart @@ -0,0 +1,34 @@ +abstract interface class NotificationReconciler { + Future reconcile(); +} + +abstract interface class OperationalNotificationReporter { + Future notifySyncFailure(Object error); + Future notifyConflict(String summary); +} + +final class CallbackOperationalNotificationReporter + implements OperationalNotificationReporter { + const CallbackOperationalNotificationReporter({ + required this.onSyncFailure, + required this.onConflict, + }); + + final Future Function(Object error) onSyncFailure; + final Future Function(String summary) onConflict; + + @override + Future notifySyncFailure(Object error) => onSyncFailure(error); + + @override + Future notifyConflict(String summary) => onConflict(summary); +} + +final class CallbackNotificationReconciler implements NotificationReconciler { + const CallbackNotificationReconciler(this._callback); + + final Future Function() _callback; + + @override + Future reconcile() => _callback(); +} diff --git a/lib/src/features/sync/account_sync_operations.dart b/lib/src/features/sync/account_sync_operations.dart index 747c0b36..894d3212 100644 --- a/lib/src/features/sync/account_sync_operations.dart +++ b/lib/src/features/sync/account_sync_operations.dart @@ -17,6 +17,18 @@ abstract interface class AccountSyncOperations { Future syncCalendar(String accountId, {required bool full}); } +abstract interface class CrossEngineAccountGate { + Future run(String accountId, Future Function() operation); +} + +final class InProcessAccountGate implements CrossEngineAccountGate { + const InProcessAccountGate(); + + @override + Future run(String accountId, Future Function() operation) => + operation(); +} + /// Serializes synchronization for each account while allowing different /// accounts to synchronize independently. /// @@ -173,6 +185,31 @@ final class CoordinatedAccountSyncOperations implements AccountSyncOperations { } } +/// Extends account serialization across foreground and WorkManager engines. +final class CrossEngineCoordinatedAccountSyncOperations + implements AccountSyncOperations { + const CrossEngineCoordinatedAccountSyncOperations({ + required AccountSyncOperations inner, + required CrossEngineAccountGate gate, + }) : _inner = inner, + _gate = gate; + + final AccountSyncOperations _inner; + final CrossEngineAccountGate _gate; + + @override + Future syncAccount(String accountId, {required bool full}) => + _gate.run(accountId, () => _inner.syncAccount(accountId, full: full)); + + @override + Future syncCalendar(String accountId, {required bool full}) => + _gate.run(accountId, () => _inner.syncCalendar(accountId, full: full)); + + @override + Future syncTasks(String accountId, {required bool full}) => + _gate.run(accountId, () => _inner.syncTasks(accountId, full: full)); +} + final class ConnectivityAwareAccountSyncOperations implements AccountSyncOperations { const ConnectivityAwareAccountSyncOperations({ diff --git a/lib/src/features/tasks/presentation/task_details_draft.dart b/lib/src/features/tasks/presentation/task_details_draft.dart index 5200cac8..42985c43 100644 --- a/lib/src/features/tasks/presentation/task_details_draft.dart +++ b/lib/src/features/tasks/presentation/task_details_draft.dart @@ -588,6 +588,7 @@ class TaskDetailsDraft { Object? microsoftReminderTime = _unchanged, String? microsoftReminderTimeZone, Object? recurrenceJson = _unchanged, + ({BusyProvider provider, RecurrenceRule rule})? creationRecurrence, String? importance, List? categories, int? icalPriority, @@ -634,7 +635,7 @@ class TaskDetailsDraft { recurrenceJson: recurrenceJson == _unchanged ? this.recurrenceJson : recurrenceJson as String?, - creationRecurrence: creationRecurrence, + creationRecurrence: creationRecurrence ?? this.creationRecurrence, importance: importance ?? this.importance, categories: categories ?? this.categories, icalPriority: icalPriority ?? this.icalPriority, diff --git a/lib/src/microsoft_todo/oauth/microsoft_oauth_service.dart b/lib/src/microsoft_todo/oauth/microsoft_oauth_service.dart index d2a9382a..58971c1a 100644 --- a/lib/src/microsoft_todo/oauth/microsoft_oauth_service.dart +++ b/lib/src/microsoft_todo/oauth/microsoft_oauth_service.dart @@ -23,7 +23,15 @@ const microsoftSignInCallbackNotReceivedMessage = 'again. If the browser opened an old tab, close it and start sign-in ' 'again.'; -class MicrosoftOAuthService { +abstract interface class MicrosoftOAuthGateway { + Future signInWithMicrosoft(); + + Future cancelSignIn(); + + Future signOutAccount(String accountId); +} + +class MicrosoftOAuthService implements MicrosoftOAuthGateway { MicrosoftOAuthService({ required BuildConfig config, required http.Client httpClient, @@ -98,6 +106,10 @@ class MicrosoftOAuthService { ); } + @override + Future signInWithMicrosoft() => signIn(); + + @override Future cancelSignIn() => _loopbackFlow.cancel(); Future readTokenSet(String accountId) { @@ -235,6 +247,7 @@ class MicrosoftOAuthService { ); } + @override Future signOutAccount(String accountId) async { await _tokenStore.deleteCredential(accountId); if (await _tokenStore.readActiveAccountId() == accountId) { diff --git a/packages/busymax_android_platform/.gitignore b/packages/busymax_android_platform/.gitignore new file mode 100644 index 00000000..b9d7f25b --- /dev/null +++ b/packages/busymax_android_platform/.gitignore @@ -0,0 +1,33 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.build/ +.buildlog/ +.history +.svn/ +.swiftpm/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +# Libraries should not include pubspec.lock, per https://dart.dev/guides/libraries/private-files#pubspeclock. +/pubspec.lock +**/doc/api/ +.dart_tool/ +.flutter-plugins-dependencies +/build/ +/coverage/ diff --git a/packages/busymax_android_platform/.metadata b/packages/busymax_android_platform/.metadata new file mode 100644 index 00000000..2791eede --- /dev/null +++ b/packages/busymax_android_platform/.metadata @@ -0,0 +1,30 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: "d3b14c876900e553bc736ca19295fc09e3853e8e" + channel: "stable" + +project_type: plugin + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: d3b14c876900e553bc736ca19295fc09e3853e8e + base_revision: d3b14c876900e553bc736ca19295fc09e3853e8e + - platform: android + create_revision: d3b14c876900e553bc736ca19295fc09e3853e8e + base_revision: d3b14c876900e553bc736ca19295fc09e3853e8e + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/packages/busymax_android_platform/CHANGELOG.md b/packages/busymax_android_platform/CHANGELOG.md new file mode 100644 index 00000000..5687aade --- /dev/null +++ b/packages/busymax_android_platform/CHANGELOG.md @@ -0,0 +1,6 @@ +## 0.1.0 + +* Added the BusyMax Android bridge for Google Identity Services, MSAL, + Storage Access Framework documents, safe external URI handoff, system clock + settings, Android 17 local-network access, activation delivery, and + cross-engine account gates. diff --git a/packages/busymax_android_platform/LICENSE b/packages/busymax_android_platform/LICENSE new file mode 100644 index 00000000..03e42fe3 --- /dev/null +++ b/packages/busymax_android_platform/LICENSE @@ -0,0 +1,13 @@ +Copyright 2026 BusyStack contributors + +Licensed under the Apache License, Version 2.0 (the "License"); you may not +use this file except in compliance with the License. You may obtain a copy of +the License at + + https://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +License for the specific language governing permissions and limitations under +the License. The repository root LICENSE contains the complete license text. diff --git a/packages/busymax_android_platform/README.md b/packages/busymax_android_platform/README.md new file mode 100644 index 00000000..559fca47 --- /dev/null +++ b/packages/busymax_android_platform/README.md @@ -0,0 +1,24 @@ +# BusyMax Android platform bridge + +This first-party Flutter plugin is the only native Android bridge used by +BusyMax. It exposes typed Dart APIs for: + +- Google Identity Services authorization and account selection; +- MSAL multiple-account interactive and silent authorization; +- secure native-to-domain account binding; +- Storage Access Framework import and export; +- time-zone and 12/24-hour system settings; +- allow-listed external URI launch and Android 17 local-network permission; +- cold/warm Android activation delivery; and +- process-wide account gates shared by foreground and WorkManager engines. + +The package is application-owned and is not intended as a general-purpose +published plugin. Provider secrets and production signing material are never +stored in this package. + +Native unit tests are run from the repository root with: + +```sh +android/gradlew --project-dir android \ + :busymax_android_platform:testDebugUnitTest +``` diff --git a/packages/busymax_android_platform/analysis_options.yaml b/packages/busymax_android_platform/analysis_options.yaml new file mode 100644 index 00000000..ff39a72e --- /dev/null +++ b/packages/busymax_android_platform/analysis_options.yaml @@ -0,0 +1,14 @@ +include: package:flutter_lints/flutter.yaml + +analyzer: + exclude: + - build/** + - android/** + - ios/** + - web/** + - windows/** + - macos/** + - linux/** + +# Additional information about this file can be found at +# https://dart.dev/guides/language/analysis-options diff --git a/packages/busymax_android_platform/android/.gitignore b/packages/busymax_android_platform/android/.gitignore new file mode 100644 index 00000000..161bdcda --- /dev/null +++ b/packages/busymax_android_platform/android/.gitignore @@ -0,0 +1,9 @@ +*.iml +.gradle +/local.properties +/.idea/workspace.xml +/.idea/libraries +.DS_Store +/build +/captures +.cxx diff --git a/packages/busymax_android_platform/android/build.gradle.kts b/packages/busymax_android_platform/android/build.gradle.kts new file mode 100644 index 00000000..43777116 --- /dev/null +++ b/packages/busymax_android_platform/android/build.gradle.kts @@ -0,0 +1,83 @@ +group = "io.busystack.busymax_android_platform" +version = "1.0-SNAPSHOT" + +buildscript { + repositories { + google() + mavenCentral() + } + + dependencies { + classpath("com.android.tools.build:gradle:9.2.0") + } +} + +allprojects { + repositories { + google() + mavenCentral() + maven { + url = uri("https://pkgs.dev.azure.com/MicrosoftDeviceSDK/DuoSDK-Public/_packaging/Duo-SDK-Feed/maven/v1") + } + } +} + +plugins { + id("com.android.library") +} + +android { + namespace = "io.busystack.busymax_android_platform" + + compileSdk = 37 + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + sourceSets { + getByName("main") { + java.srcDirs("src/main/kotlin") + } + getByName("test") { + java.srcDirs("src/test/kotlin") + } + } + + defaultConfig { + minSdk = 24 + manifestPlaceholders["busymaxMsalRedirectHost"] = "io.busystack.busymax" + manifestPlaceholders["busymaxMsalSignatureHash"] = "not-configured" + } + + testOptions { + unitTests { + isIncludeAndroidResources = true + all { + it.useJUnitPlatform() + + it.outputs.upToDateWhen { false } + + it.testLogging { + events("passed", "skipped", "failed", "standardOut", "standardError") + showStandardStreams = true + } + } + } + } +} + +kotlin { + compilerOptions { + jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17 + } +} + +dependencies { + implementation("com.google.android.gms:play-services-auth:21.5.0") + implementation("com.microsoft.identity.client:msal:8.4.1") + testImplementation("org.junit.jupiter:junit-jupiter:5.11.4") + testImplementation("org.mockito:mockito-core:5.0.0") + testRuntimeOnly("org.junit.platform:junit-platform-launcher:1.11.4") +} diff --git a/packages/busymax_android_platform/android/settings.gradle.kts b/packages/busymax_android_platform/android/settings.gradle.kts new file mode 100644 index 00000000..d2f5aeac --- /dev/null +++ b/packages/busymax_android_platform/android/settings.gradle.kts @@ -0,0 +1 @@ +rootProject.name = "busymax_android_platform" diff --git a/packages/busymax_android_platform/android/src/main/AndroidManifest.xml b/packages/busymax_android_platform/android/src/main/AndroidManifest.xml new file mode 100644 index 00000000..e8db4cea --- /dev/null +++ b/packages/busymax_android_platform/android/src/main/AndroidManifest.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + + diff --git a/packages/busymax_android_platform/android/src/main/kotlin/io/busystack/busymax_android_platform/BusymaxAndroidPlatformPlugin.kt b/packages/busymax_android_platform/android/src/main/kotlin/io/busystack/busymax_android_platform/BusymaxAndroidPlatformPlugin.kt new file mode 100644 index 00000000..39f7883f --- /dev/null +++ b/packages/busymax_android_platform/android/src/main/kotlin/io/busystack/busymax_android_platform/BusymaxAndroidPlatformPlugin.kt @@ -0,0 +1,597 @@ +package io.busystack.busymax_android_platform + +import android.accounts.Account +import android.app.Activity +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.content.pm.PackageManager +import android.database.Cursor +import android.net.Uri +import android.os.Build +import android.os.Handler +import android.os.Looper +import android.provider.DocumentsContract +import android.provider.OpenableColumns +import android.provider.Settings +import android.text.format.DateFormat +import androidx.core.content.ContextCompat +import com.google.android.gms.auth.GoogleAuthUtil +import com.google.android.gms.auth.api.identity.AuthorizationRequest +import com.google.android.gms.auth.api.identity.AuthorizationResult +import com.google.android.gms.auth.api.identity.ClearTokenRequest +import com.google.android.gms.auth.api.identity.Identity +import com.google.android.gms.auth.api.identity.RevokeAccessRequest +import com.google.android.gms.common.GoogleApiAvailability +import com.google.android.gms.common.api.Scope +import com.microsoft.identity.client.AcquireTokenParameters +import com.microsoft.identity.client.AcquireTokenSilentParameters +import com.microsoft.identity.client.AuthenticationCallback +import com.microsoft.identity.client.IAuthenticationResult +import com.microsoft.identity.client.IMultipleAccountPublicClientApplication +import com.microsoft.identity.client.Prompt +import com.microsoft.identity.client.PublicClientApplication +import com.microsoft.identity.client.exception.MsalException +import io.flutter.embedding.engine.plugins.FlutterPlugin +import io.flutter.embedding.engine.plugins.activity.ActivityAware +import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding +import io.flutter.plugin.common.EventChannel +import io.flutter.plugin.common.MethodCall +import io.flutter.plugin.common.MethodChannel +import io.flutter.plugin.common.PluginRegistry +import org.json.JSONObject +import java.io.ByteArrayOutputStream +import java.util.TimeZone +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.Executors +import java.util.concurrent.Semaphore +import java.util.concurrent.TimeUnit + +/** BusyMax-owned Android integration. Provider refresh tokens never enter Dart. */ +class BusymaxAndroidPlatformPlugin : FlutterPlugin, + MethodChannel.MethodCallHandler, + EventChannel.StreamHandler, + ActivityAware, + PluginRegistry.ActivityResultListener, + PluginRegistry.NewIntentListener, + PluginRegistry.RequestPermissionsResultListener { + + private lateinit var context: Context + private lateinit var channel: MethodChannel + private lateinit var eventChannel: EventChannel + private var activity: Activity? = null + private var activityBinding: ActivityPluginBinding? = null + private var eventSink: EventChannel.EventSink? = null + private val main by lazy { Handler(Looper.getMainLooper()) } + private val executor = Executors.newCachedThreadPool() + private var pendingInteractive: PendingInteractive? = null + private var pendingDocument: PendingDocument? = null + private var pendingPermission: MethodChannel.Result? = null + @Volatile private var msal: IMultipleAccountPublicClientApplication? = null + private var initialActivation: Map? = null + + private val settingsReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) { + eventSink?.success(mapOf("kind" to "systemSettingsChanged")) + } + } + + override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) { + context = binding.applicationContext + channel = MethodChannel(binding.binaryMessenger, METHOD_CHANNEL) + eventChannel = EventChannel(binding.binaryMessenger, EVENT_CHANNEL) + channel.setMethodCallHandler(this) + eventChannel.setStreamHandler(this) + activePlugins.add(this) + val filter = IntentFilter().apply { + addAction(Intent.ACTION_TIME_CHANGED) + addAction(Intent.ACTION_TIMEZONE_CHANGED) + addAction(Intent.ACTION_LOCALE_CHANGED) + } + ContextCompat.registerReceiver(context, settingsReceiver, filter, ContextCompat.RECEIVER_NOT_EXPORTED) + } + + override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) { + activePlugins.remove(this) + channel.setMethodCallHandler(null) + eventChannel.setStreamHandler(null) + try { context.unregisterReceiver(settingsReceiver) } catch (_: Exception) {} + executor.shutdown() + } + + override fun onListen(arguments: Any?, events: EventChannel.EventSink?) { eventSink = events } + override fun onCancel(arguments: Any?) { eventSink = null } + override fun onAttachedToActivity(binding: ActivityPluginBinding) = attachActivity(binding) + override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) = attachActivity(binding) + + private fun attachActivity(binding: ActivityPluginBinding) { + activityBinding = binding + activity = binding.activity + binding.addActivityResultListener(this) + binding.addOnNewIntentListener(this) + binding.addRequestPermissionsResultListener(this) + captureActivation(binding.activity.intent) + } + + override fun onDetachedFromActivityForConfigChanges() = detachActivity() + override fun onDetachedFromActivity() = detachActivity() + + private fun detachActivity() { + activityBinding?.removeActivityResultListener(this) + activityBinding?.removeOnNewIntentListener(this) + activityBinding?.removeRequestPermissionsResultListener(this) + activityBinding = null + activity = null + } + + override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { + when (call.method) { + "currentTimeZoneId" -> result.success(TimeZone.getDefault().id) + "uses24HourFormat" -> result.success(DateFormat.is24HourFormat(context)) + "googleAuthorizationAvailable" -> result.success(GoogleApiAvailability.getInstance().isGooglePlayServicesAvailable(context) == 0) + "microsoftAuthorizationAvailable" -> result.success( + context.resources.getIdentifier("busymax_msal_config", "raw", context.packageName) != 0, + ) + "authorizeGoogleInteractive" -> authorizeGoogle(call, result, true) + "authorizeGoogleSilent" -> authorizeGoogle(call, result, false) + "authorizeMicrosoftInteractive" -> authorizeMicrosoft(call, result, true) + "authorizeMicrosoftSilent" -> authorizeMicrosoft(call, result, false) + "bindAuthorization" -> bindAuthorization(call, result) + "clearRejectedGoogleToken" -> clearGoogleToken(call, result) + "removeAuthorization" -> removeAuthorization(call, result) + "cancelInteractiveAuthorization" -> cancelInteractive(result) + "openDocument" -> openDocument(call, result) + "readDocumentUri" -> readDocumentUri(call, result) + "createDocument" -> createDocument(call, result) + "exportDocumentTree" -> exportDocumentTree(call, result) + "launchExternalUri" -> launchExternalUri(call, result) + "hasLocalNetworkAccess" -> result.success(hasLocalNetworkAccess()) + "requestLocalNetworkAccess" -> requestLocalNetworkAccess(result) + "openNotificationSettings" -> { + val intent = Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).apply { + putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName) + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + context.startActivity(intent) + result.success(null) + } + "acquireAccountGate" -> acquireAccountGate(call, result) + "releaseAccountGate" -> releaseAccountGate(call, result) + "notifyDataChanged" -> { + activePlugins.forEach { plugin -> + plugin.eventSink?.success(mapOf("kind" to "dataChanged")) + } + result.success(null) + } + "takeInitialActivation" -> { + val activation = initialActivation + initialActivation = null + result.success(activation) + } + else -> result.notImplemented() + } + } + + private fun authorizeGoogle(call: MethodCall, result: MethodChannel.Result, interactive: Boolean) { + val scopes = stringList(call.argument>("scopes")) + if (scopes.isEmpty()) return result.error("android/invalid-arguments", "Scopes are required.", null) + if (interactive && !reserveInteractive("google", result)) return + val builder = AuthorizationRequest.builder() + .setRequestedScopes(scopes.map(::Scope)) + .setPrompt(if (interactive) AuthorizationRequest.Prompt.SELECT_ACCOUNT else AuthorizationRequest.Prompt.NOT_SET) + if (!interactive) { + val binding = readBinding("google", call.argument("accountId").orEmpty()) + val username = binding?.optString("username")?.takeIf { it.isNotBlank() } + if (username == null) return result.error("android/auth-interaction-required", "Google authorization must be reconnected.", null) + builder.setAccount(Account(username, GoogleAuthUtil.GOOGLE_ACCOUNT_TYPE)) + } + Identity.getAuthorizationClient(context).authorize(builder.build()) + .addOnSuccessListener { authorization -> + if (authorization.hasResolution()) { + val host = activity + if (!interactive || host == null) { + if (interactive) clearPendingInteractive() + result.error("android/auth-interaction-required", "Authorization requires user interaction.", null) + return@addOnSuccessListener + } + try { + host.startIntentSenderForResult(authorization.pendingIntent!!.intentSender, GOOGLE_AUTH_REQUEST, null, 0, 0, 0) + } catch (error: Exception) { + clearPendingInteractive() + result.error("android/auth-launch-failed", "Google authorization could not be opened.", safeError(error)) + } + } else { + if (interactive) clearPendingInteractive() + completeGoogleAuthorization(authorization, result) + } + } + .addOnFailureListener { error -> + if (interactive) clearPendingInteractive() + result.error("android/google-authorization-failed", "Google authorization failed.", safeError(error)) + } + } + + private fun completeGoogleAuthorization(authorization: AuthorizationResult, result: MethodChannel.Result) { + val token = authorization.accessToken.orEmpty() + val account = authorization.toGoogleSignInAccount() + val nativeId = account?.id ?: account?.email + if (token.isBlank() || nativeId.isNullOrBlank()) { + result.error("android/google-result-invalid", "Google returned an incomplete authorization result.", null) + return + } + result.success(mapOf("accessToken" to token, "scopes" to authorization.grantedScopes, "nativeAccountId" to nativeId, "username" to account?.email)) + } + + private fun authorizeMicrosoft(call: MethodCall, result: MethodChannel.Result, interactive: Boolean) { + val scopes = stringList(call.argument>("scopes")) + if (scopes.isEmpty()) return result.error("android/invalid-arguments", "Scopes are required.", null) + if (interactive && !reserveInteractive("microsoft", result)) return + ensureMsal( + onSuccess = { application -> + if (interactive) { + val host = activity + if (host == null) { + clearPendingInteractive() + result.error("android/activity-unavailable", "Microsoft sign-in requires a visible Activity.", null) + return@ensureMsal + } + val parameters = AcquireTokenParameters.Builder() + .startAuthorizationFromActivity(host) + .withScopes(scopes) + .withPrompt(Prompt.SELECT_ACCOUNT) + .withCallback(object : AuthenticationCallback { + override fun onSuccess(authenticationResult: IAuthenticationResult) { + if (takePendingInteractive("microsoft") == null) return + result.success(msalResult(authenticationResult)) + } + override fun onError(exception: MsalException) { + if (takePendingInteractive("microsoft") == null) return + result.error("android/microsoft-authorization-failed", "Microsoft authorization failed.", safeError(exception)) + } + override fun onCancel() { + if (takePendingInteractive("microsoft") == null) return + result.error("android/auth-cancelled", "Microsoft sign-in was cancelled.", null) + } + }).build() + application.acquireToken(parameters) + } else { + val binding = readBinding("microsoft", call.argument("accountId").orEmpty()) + val nativeId = binding?.optString("nativeAccountId").orEmpty() + executor.execute { + try { + val account = application.accounts.firstOrNull { it.id == nativeId } + ?: throw IllegalStateException("Microsoft account is not in the MSAL cache.") + val parameters = AcquireTokenSilentParameters.Builder() + .withScopes(scopes).forAccount(account).fromAuthority(account.authority).build() + postSuccess(result, msalResult(application.acquireTokenSilent(parameters))) + } catch (error: Exception) { + postError(result, "android/auth-interaction-required", "Microsoft authorization must be reconnected.", error) + } + } + } + }, + onError = { error -> + if (interactive) clearPendingInteractive() + result.error("android/microsoft-not-configured", "Microsoft sign-in is not configured for this build.", safeError(error)) + }, + ) + } + + private fun ensureMsal(onSuccess: (IMultipleAccountPublicClientApplication) -> Unit, onError: (Throwable) -> Unit) { + msal?.let { return onSuccess(it) } + val resourceId = context.resources.getIdentifier("busymax_msal_config", "raw", context.packageName) + if (resourceId == 0) return onError(IllegalStateException("Missing generated MSAL configuration.")) + executor.execute { + try { + val created = PublicClientApplication.createMultipleAccountPublicClientApplication(context, resourceId) + msal = created + main.post { onSuccess(created) } + } catch (error: Throwable) { main.post { onError(error) } } + } + } + + private fun msalResult(token: IAuthenticationResult): Map = mapOf( + "accessToken" to token.accessToken, + "scopes" to token.scope.toList(), + "nativeAccountId" to token.account.id, + "username" to token.account.username, + "authority" to token.account.authority, + "expiresAtEpochMillis" to token.expiresOn.time, + ) + + private fun bindAuthorization(call: MethodCall, result: MethodChannel.Result) { + val provider = call.argument("provider").orEmpty() + val accountId = call.argument("accountId").orEmpty() + val nativeId = call.argument("nativeAccountId").orEmpty() + if (provider !in setOf("google", "microsoft") || accountId.isBlank() || nativeId.isBlank()) { + result.error("android/invalid-arguments", "Authorization binding is incomplete.", null) + return + } + val value = JSONObject().put("provider", provider).put("accountId", accountId) + .put("nativeAccountId", nativeId).put("username", call.argument("username")) + .put("authority", call.argument("authority")) + bindingPreferences().edit().putString(bindingKey(provider, accountId), value.toString()).apply() + result.success(null) + } + + private fun clearGoogleToken(call: MethodCall, result: MethodChannel.Result) { + val token = call.argument("token").orEmpty() + if (token.isBlank()) return result.success(null) + Identity.getAuthorizationClient(context).clearToken(ClearTokenRequest.builder().setToken(token).build()) + .addOnSuccessListener { result.success(null) } + .addOnFailureListener { result.error("android/google-clear-failed", "Rejected Google token could not be cleared.", safeError(it)) } + } + + private fun removeAuthorization(call: MethodCall, result: MethodChannel.Result) { + val provider = call.argument("provider").orEmpty() + val accountId = call.argument("accountId").orEmpty() + val binding = readBinding(provider, accountId) + fun removeBinding() { bindingPreferences().edit().remove(bindingKey(provider, accountId)).apply() } + if (provider == "google" && call.argument("revoke") == true) { + val username = binding?.optString("username").orEmpty() + if (username.isBlank()) { removeBinding(); return result.success(null) } + Identity.getAuthorizationClient(context).revokeAccess( + RevokeAccessRequest.builder().setAccount(Account(username, GoogleAuthUtil.GOOGLE_ACCOUNT_TYPE)).build(), + ).addOnSuccessListener { removeBinding(); result.success(null) } + .addOnFailureListener { result.error("android/google-revoke-failed", "Google access could not be revoked.", safeError(it)) } + return + } + if (provider == "microsoft" && binding != null) { + val nativeId = binding.optString("nativeAccountId") + ensureMsal(onSuccess = { application -> executor.execute { + try { + application.accounts.firstOrNull { it.id == nativeId }?.let(application::removeAccount) + removeBinding(); postSuccess(result, null) + } catch (error: Exception) { postError(result, "android/microsoft-remove-failed", "Microsoft account could not be removed.", error) } + } }, onError = { result.error("android/microsoft-not-configured", "Microsoft sign-in is not configured.", safeError(it)) }) + return + } + removeBinding(); result.success(null) + } + + private fun reserveInteractive(provider: String, result: MethodChannel.Result): Boolean { + if (activity == null) { result.error("android/activity-unavailable", "Sign-in requires a visible Activity.", null); return false } + if (pendingInteractive != null) { result.error("android/auth-in-progress", "Another sign-in is already in progress.", null); return false } + pendingInteractive = PendingInteractive(provider, result) + return true + } + + private fun takePendingInteractive(provider: String): PendingInteractive? { + val pending = pendingInteractive + if (pending?.provider != provider) return null + pendingInteractive = null + return pending + } + + private fun clearPendingInteractive() { pendingInteractive = null } + private fun cancelInteractive(result: MethodChannel.Result) { + val pending = pendingInteractive + pendingInteractive = null + pending?.result?.error("android/auth-cancelled", "Sign-in was cancelled.", null) + result.success(null) + } + + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?): Boolean { + if (requestCode == GOOGLE_AUTH_REQUEST) { + val pending = takePendingInteractive("google") ?: return true + if (resultCode != Activity.RESULT_OK || data == null) { + pending.result.error("android/auth-cancelled", "Google sign-in was cancelled.", null) + return true + } + try { completeGoogleAuthorization(Identity.getAuthorizationClient(context).getAuthorizationResultFromIntent(data), pending.result) } + catch (error: Exception) { pending.result.error("android/google-authorization-failed", "Google authorization failed.", safeError(error)) } + return true + } + val pending = pendingDocument ?: return false + if (requestCode != pending.requestCode) return false + pendingDocument = null + if (resultCode != Activity.RESULT_OK || data?.data == null) { pending.result.success(null); return true } + val uri = data.data!! + try { + when (pending) { + is PendingDocument.Open -> finishOpenDocument(uri, pending) + is PendingDocument.Create -> finishCreateDocument(uri, pending) + is PendingDocument.Tree -> finishTreeExport(uri, pending) + } + } catch (error: Exception) { pending.result.error("android/document-failed", "The selected document could not be processed.", safeError(error)) } + return true + } + + private fun openDocument(call: MethodCall, result: MethodChannel.Result) { + val host = requireDocumentActivity(result) ?: return + if (pendingDocument != null) return result.error("android/document-in-progress", "A document picker is already open.", null) + val types = stringList(call.argument>("mimeTypes")).ifEmpty { listOf("text/calendar") } + val max = (call.argument("maximumBytes")?.toLong() ?: MAX_IMPORT_BYTES).coerceIn(1, MAX_IMPORT_BYTES) + val intent = Intent(Intent.ACTION_OPEN_DOCUMENT).apply { + addCategory(Intent.CATEGORY_OPENABLE); type = if (types.size == 1) types.first() else "*/*" + if (types.size > 1) putExtra(Intent.EXTRA_MIME_TYPES, types.toTypedArray()) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION) + } + pendingDocument = PendingDocument.Open(result, max) + host.startActivityForResult(intent, OPEN_DOCUMENT_REQUEST) + } + + private fun finishOpenDocument(uri: Uri, pending: PendingDocument.Open) { + tryPersistPermission(uri, Intent.FLAG_GRANT_READ_URI_PERMISSION) + pending.result.success(readDocument(uri, pending.maximumBytes)) + } + + private fun readDocumentUri(call: MethodCall, result: MethodChannel.Result) { + val uri = Uri.parse(call.argument("uri").orEmpty()) + if (uri.scheme != "content") return result.error("android/invalid-document-uri", "Only content document URIs are supported.", null) + val max = (call.argument("maximumBytes")?.toLong() ?: MAX_IMPORT_BYTES).coerceIn(1, MAX_IMPORT_BYTES) + try { result.success(readDocument(uri, max)) } + catch (error: Exception) { result.error("android/document-failed", "The shared document could not be read.", safeError(error)) } + } + + private fun readDocument(uri: Uri, maximumBytes: Long): Map { + val bytes = context.contentResolver.openInputStream(uri)?.use { input -> + val output = ByteArrayOutputStream(); val buffer = ByteArray(16 * 1024); var total = 0L + while (true) { val count = input.read(buffer); if (count < 0) break; total += count + if (total > maximumBytes) throw IllegalArgumentException("Document exceeds the import size limit.") + output.write(buffer, 0, count) } + output.toByteArray() + } ?: throw IllegalArgumentException("Document could not be opened.") + return mapOf("uri" to uri.toString(), "bytes" to bytes, "name" to displayName(uri), "mimeType" to context.contentResolver.getType(uri)) + } + + private fun createDocument(call: MethodCall, result: MethodChannel.Result) { + val host = requireDocumentActivity(result) ?: return + if (pendingDocument != null) return result.error("android/document-in-progress", "A document picker is already open.", null) + val bytes = call.argument("bytes") ?: return result.error("android/invalid-arguments", "Export bytes are required.", null) + if (bytes.size > MAX_EXPORT_BYTES) return result.error("android/export-too-large", "Export exceeds the size limit.", null) + pendingDocument = PendingDocument.Create(result, bytes) + host.startActivityForResult(Intent(Intent.ACTION_CREATE_DOCUMENT).apply { + addCategory(Intent.CATEGORY_OPENABLE); type = call.argument("mimeType")?.takeIf { it.isNotBlank() } ?: "text/calendar" + putExtra(Intent.EXTRA_TITLE, safeFileName(call.argument("suggestedName").orEmpty(), "busymax.ics")) + addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION or Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION) + }, CREATE_DOCUMENT_REQUEST) + } + + private fun finishCreateDocument(uri: Uri, pending: PendingDocument.Create) { + tryPersistPermission(uri, Intent.FLAG_GRANT_WRITE_URI_PERMISSION) + context.contentResolver.openOutputStream(uri, "wt")?.use { it.write(pending.bytes) } + ?: throw IllegalArgumentException("Document could not be created.") + pending.result.success(uri.toString()) + } + + private fun exportDocumentTree(call: MethodCall, result: MethodChannel.Result) { + val host = requireDocumentActivity(result) ?: return + if (pendingDocument != null) return result.error("android/document-in-progress", "A document picker is already open.", null) + val raw = call.argument>("resources") ?: emptyList() + if (raw.size > MAX_EXPORT_RESOURCES) return result.error("android/export-too-large", "Export contains too many resources.", null) + var total = 0L + val resources = raw.mapIndexed { index, value -> + val map = value as? Map<*, *> ?: throw IllegalArgumentException("Invalid export resource.") + val bytes = map["bytes"] as? ByteArray ?: throw IllegalArgumentException("Missing export bytes.") + total += bytes.size + ExportResource(safeFileName(map["name"]?.toString().orEmpty(), "resource-${index + 1}.ics"), bytes) + } + if (total > MAX_TREE_EXPORT_BYTES) return result.error("android/export-too-large", "Export exceeds the size limit.", null) + pendingDocument = PendingDocument.Tree(result, safeFileName(call.argument("folderName").orEmpty(), "BusyMax export"), resources) + host.startActivityForResult(Intent(Intent.ACTION_OPEN_DOCUMENT_TREE).apply { + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION or Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION or Intent.FLAG_GRANT_PREFIX_URI_PERMISSION) + }, OPEN_TREE_REQUEST) + } + + private fun finishTreeExport(uri: Uri, pending: PendingDocument.Tree) { + tryPersistPermission(uri, Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION) + val resolver = context.contentResolver + val root = DocumentsContract.buildDocumentUriUsingTree(uri, DocumentsContract.getTreeDocumentId(uri)) + val folder = DocumentsContract.createDocument(resolver, root, DocumentsContract.Document.MIME_TYPE_DIR, pending.folderName) + ?: throw IllegalStateException("Could not create the export folder.") + for (resource in pending.resources) { + val output = DocumentsContract.createDocument(resolver, folder, "text/calendar", resource.name) + ?: throw IllegalStateException("Could not create ${resource.name}.") + resolver.openOutputStream(output, "wt")?.use { it.write(resource.bytes) } + ?: throw IllegalStateException("Could not write ${resource.name}.") + } + pending.result.success(folder.toString()) + } + + private fun launchExternalUri(call: MethodCall, result: MethodChannel.Result) { + val uri = Uri.parse(call.argument("uri").orEmpty()) + if (uri.scheme?.lowercase() !in setOf("https", "http", "geo", "mailto")) return result.error("android/uri-not-allowed", "This link type is not allowed.", null) + val intent = Intent(Intent.ACTION_VIEW, uri).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + if (intent.resolveActivity(context.packageManager) == null) return result.success(false) + context.startActivity(intent); result.success(true) + } + + private fun hasLocalNetworkAccess() = Build.VERSION.SDK_INT < 37 || ContextCompat.checkSelfPermission(context, ACCESS_LOCAL_NETWORK) == PackageManager.PERMISSION_GRANTED + private fun requestLocalNetworkAccess(result: MethodChannel.Result) { + if (hasLocalNetworkAccess()) return result.success(true) + val host = activity ?: return result.error("android/activity-unavailable", "Local network permission requires a visible Activity.", null) + if (pendingPermission != null) return result.error("android/permission-in-progress", "Another permission request is active.", null) + pendingPermission = result + host.requestPermissions(arrayOf(ACCESS_LOCAL_NETWORK), LOCAL_NETWORK_REQUEST) + } + + override fun onRequestPermissionsResult(requestCode: Int, permissions: Array, grantResults: IntArray): Boolean { + if (requestCode != LOCAL_NETWORK_REQUEST) return false + val result = pendingPermission ?: return true + pendingPermission = null; result.success(grantResults.firstOrNull() == PackageManager.PERMISSION_GRANTED); return true + } + + private fun acquireAccountGate(call: MethodCall, result: MethodChannel.Result) { + val accountId = call.argument("accountId").orEmpty() + val timeout = (call.argument("timeoutMillis")?.toLong() ?: 30_000L).coerceIn(1_000L, 120_000L) + if (accountId.isBlank()) return result.error("android/invalid-arguments", "Account id is required.", null) + executor.execute { + val semaphore = accountGates.computeIfAbsent(accountId) { Semaphore(1, true) } + try { + if (!semaphore.tryAcquire(timeout, TimeUnit.MILLISECONDS)) return@execute postError(result, "android/account-busy", "This account is already synchronizing.", null) + val lease = UUID.randomUUID().toString(); leases[lease] = semaphore; postSuccess(result, lease) + } catch (error: InterruptedException) { + Thread.currentThread().interrupt(); postError(result, "android/account-gate-interrupted", "Account synchronization was interrupted.", error) + } + } + } + + private fun releaseAccountGate(call: MethodCall, result: MethodChannel.Result) { leases.remove(call.argument("leaseId"))?.release(); result.success(null) } + override fun onNewIntent(intent: Intent): Boolean = captureActivation(intent) + private fun captureActivation(intent: Intent?): Boolean { + if (intent == null) return false + val activation = when (intent.action) { + Intent.ACTION_VIEW -> intent.data?.let { mapOf("kind" to "document", "uri" to it.toString(), "mimeType" to intent.type) } + Intent.ACTION_SEND -> sharedStreamUri(intent)?.let { mapOf("kind" to "document", "uri" to it.toString(), "mimeType" to intent.type) } + else -> null + } ?: return false + intent.action = null + initialActivation = activation; eventSink?.success(activation); return true + } + + private fun sharedStreamUri(intent: Intent): Uri? = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + intent.getParcelableExtra(Intent.EXTRA_STREAM, Uri::class.java) + } else { + @Suppress("DEPRECATION") + intent.getParcelableExtra(Intent.EXTRA_STREAM) + } + + private fun requireDocumentActivity(result: MethodChannel.Result): Activity? = activity.also { + if (it == null) result.error("android/activity-unavailable", "A document picker requires a visible Activity.", null) + } + private fun bindingPreferences() = context.getSharedPreferences("busymax_android_authorization", Context.MODE_PRIVATE) + private fun bindingKey(provider: String, accountId: String) = "$provider|$accountId" + private fun readBinding(provider: String, accountId: String): JSONObject? = bindingPreferences().getString(bindingKey(provider, accountId), null)?.let { + try { JSONObject(it) } catch (_: Exception) { null } + } + private fun tryPersistPermission(uri: Uri, flags: Int) { try { context.contentResolver.takePersistableUriPermission(uri, flags) } catch (_: Exception) {} } + private fun displayName(uri: Uri): String? { + var cursor: Cursor? = null + return try { cursor = context.contentResolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null); if (cursor != null && cursor.moveToFirst()) cursor.getString(0) else null } + finally { cursor?.close() } + } + private fun safeFileName(value: String, fallback: String): String = value.trim().replace(Regex("[\\\\/:*?\"<>|\\p{Cntrl}]"), "_").take(120).ifBlank { fallback } + private fun stringList(values: List<*>?) = values.orEmpty().mapNotNull { it?.toString()?.trim()?.takeIf(String::isNotEmpty) }.distinct() + private fun safeError(error: Throwable) = error.javaClass.simpleName + private fun postSuccess(result: MethodChannel.Result, value: Any?) { main.post { result.success(value) } } + private fun postError(result: MethodChannel.Result, code: String, message: String, error: Throwable?) { main.post { result.error(code, message, error?.let(::safeError)) } } + + private data class PendingInteractive(val provider: String, val result: MethodChannel.Result) + private data class ExportResource(val name: String, val bytes: ByteArray) + private sealed class PendingDocument(val result: MethodChannel.Result, val requestCode: Int) { + class Open(result: MethodChannel.Result, val maximumBytes: Long) : PendingDocument(result, OPEN_DOCUMENT_REQUEST) + class Create(result: MethodChannel.Result, val bytes: ByteArray) : PendingDocument(result, CREATE_DOCUMENT_REQUEST) + class Tree(result: MethodChannel.Result, val folderName: String, val resources: List) : PendingDocument(result, OPEN_TREE_REQUEST) + } + + companion object { + private const val METHOD_CHANNEL = "io.busystack.busymax/android" + private const val EVENT_CHANNEL = "io.busystack.busymax/events" + private const val GOOGLE_AUTH_REQUEST = 7101 + private const val OPEN_DOCUMENT_REQUEST = 7102 + private const val CREATE_DOCUMENT_REQUEST = 7103 + private const val OPEN_TREE_REQUEST = 7104 + private const val LOCAL_NETWORK_REQUEST = 7105 + private const val ACCESS_LOCAL_NETWORK = "android.permission.ACCESS_LOCAL_NETWORK" + private const val MAX_IMPORT_BYTES = 16L * 1024L * 1024L + private const val MAX_EXPORT_BYTES = 32 * 1024 * 1024 + private const val MAX_TREE_EXPORT_BYTES = 64L * 1024L * 1024L + private const val MAX_EXPORT_RESOURCES = 512 + private val accountGates = ConcurrentHashMap() + private val leases = ConcurrentHashMap() + private val activePlugins = ConcurrentHashMap.newKeySet() + } +} diff --git a/packages/busymax_android_platform/android/src/test/kotlin/io/busystack/busymax_android_platform/BusymaxAndroidPlatformPluginTest.kt b/packages/busymax_android_platform/android/src/test/kotlin/io/busystack/busymax_android_platform/BusymaxAndroidPlatformPluginTest.kt new file mode 100644 index 00000000..34597088 --- /dev/null +++ b/packages/busymax_android_platform/android/src/test/kotlin/io/busystack/busymax_android_platform/BusymaxAndroidPlatformPluginTest.kt @@ -0,0 +1,17 @@ +package io.busystack.busymax_android_platform + +import io.flutter.plugin.common.MethodCall +import io.flutter.plugin.common.MethodChannel +import org.junit.jupiter.api.Test +import org.mockito.Mockito + +internal class BusymaxAndroidPlatformPluginTest { + @Test + fun unknownMethod_isReportedAsNotImplemented() { + val plugin = BusymaxAndroidPlatformPlugin() + val call = MethodCall("unsupported", null) + val mockResult: MethodChannel.Result = Mockito.mock(MethodChannel.Result::class.java) + plugin.onMethodCall(call, mockResult) + Mockito.verify(mockResult).notImplemented() + } +} diff --git a/packages/busymax_android_platform/example/.gitignore b/packages/busymax_android_platform/example/.gitignore new file mode 100644 index 00000000..79f7ecab --- /dev/null +++ b/packages/busymax_android_platform/example/.gitignore @@ -0,0 +1,48 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.build/ +.buildlog/ +.history +.svn/ +.swiftpm/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +**/ios/Flutter/.last_build_id +.dart_tool/ +.flutter-plugins-dependencies +.pub-cache/ +.pub/ +/build/ +/coverage/ + +# Symbolication related +app.*.symbols + +# Obfuscation related +app.*.map.json + +# Android Studio will place build artifacts here +/android/app/debug +/android/app/profile +/android/app/release + +# Widget Preview related +.widget_preview/ diff --git a/packages/busymax_android_platform/example/README.md b/packages/busymax_android_platform/example/README.md new file mode 100644 index 00000000..8c946ca2 --- /dev/null +++ b/packages/busymax_android_platform/example/README.md @@ -0,0 +1,17 @@ +# busymax_android_platform_example + +Demonstrates how to use the busymax_android_platform plugin. + +## Getting Started + +This project is a starting point for a Flutter application. + +A few resources to get you started if this is your first Flutter project: + +- [Learn Flutter](https://docs.flutter.dev/get-started/learn-flutter) +- [Write your first Flutter app](https://docs.flutter.dev/get-started/codelab) +- [Flutter learning resources](https://docs.flutter.dev/reference/learning-resources) + +For help getting started with Flutter development, view the +[online documentation](https://docs.flutter.dev/), which offers tutorials, +samples, guidance on mobile development, and a full API reference. diff --git a/packages/busymax_android_platform/example/analysis_options.yaml b/packages/busymax_android_platform/example/analysis_options.yaml new file mode 100644 index 00000000..ae2e3eaa --- /dev/null +++ b/packages/busymax_android_platform/example/analysis_options.yaml @@ -0,0 +1,33 @@ +# This file configures the analyzer, which statically analyzes Dart code to +# check for errors, warnings, and lints. +# +# The issues identified by the analyzer are surfaced in the UI of Dart-enabled +# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be +# invoked from the command line by running `flutter analyze`. + +# The following line activates a set of recommended lints for Flutter apps, +# packages, and plugins designed to encourage good coding practices. +include: package:flutter_lints/flutter.yaml + +analyzer: + exclude: + - build/** + - android/** + +linter: + # The lint rules applied to this project can be customized in the + # section below to disable rules from the `package:flutter_lints/flutter.yaml` + # included above or to enable additional rules. A list of all available lints + # and their documentation is published at https://dart.dev/lints. + # + # Instead of disabling a lint rule for the entire project in the + # section below, it can also be suppressed for a single line of code + # or a specific dart file by using the `// ignore: name_of_lint` and + # `// ignore_for_file: name_of_lint` syntax on the line or in the file + # producing the lint. + rules: + # avoid_print: false # Uncomment to disable the `avoid_print` rule + # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule + +# Additional information about this file can be found at +# https://dart.dev/guides/language/analysis-options diff --git a/packages/busymax_android_platform/example/android/.gitignore b/packages/busymax_android_platform/example/android/.gitignore new file mode 100644 index 00000000..be3943c9 --- /dev/null +++ b/packages/busymax_android_platform/example/android/.gitignore @@ -0,0 +1,14 @@ +gradle-wrapper.jar +/.gradle +/captures/ +/gradlew +/gradlew.bat +/local.properties +GeneratedPluginRegistrant.java +.cxx/ + +# Remember to never publicly share your keystore. +# See https://flutter.dev/to/reference-keystore +key.properties +**/*.keystore +**/*.jks diff --git a/packages/busymax_android_platform/example/android/app/build.gradle.kts b/packages/busymax_android_platform/example/android/app/build.gradle.kts new file mode 100644 index 00000000..9497a45d --- /dev/null +++ b/packages/busymax_android_platform/example/android/app/build.gradle.kts @@ -0,0 +1,47 @@ +plugins { + id("com.android.application") + // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. + id("dev.flutter.flutter-gradle-plugin") +} + +android { + namespace = "io.busystack.busymax_android_platform_example" + compileSdk = flutter.compileSdkVersion + ndkVersion = flutter.ndkVersion + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + defaultConfig { + applicationId = "io.busystack.busymax_android_platform_example" + // You can update the following values to match your application needs. + // For more information, see: https://flutter.dev/to/review-gradle-config. + minSdk = flutter.minSdkVersion + targetSdk = flutter.targetSdkVersion + // Uses the version code from pubspec.yaml. When using split APKs, 1000 * ABI_VERSION + // is added automatically by Flutter. (https://developer.android.com/studio/build/configure-apk-splits#configure-APK-versions) + // You can force using the value of versionCode by specifying the `-P force-version-code-ignoring-abi=true` + // flag during build. + versionCode = flutter.versionCode + versionName = flutter.versionName + } + + buildTypes { + release { + // This non-distributable example intentionally uses the debug key. + signingConfig = signingConfigs.getByName("debug") + } + } +} + +kotlin { + compilerOptions { + jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17 + } +} + +flutter { + source = "../.." +} diff --git a/packages/busymax_android_platform/example/android/app/src/debug/AndroidManifest.xml b/packages/busymax_android_platform/example/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 00000000..399f6981 --- /dev/null +++ b/packages/busymax_android_platform/example/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/packages/busymax_android_platform/example/android/app/src/main/AndroidManifest.xml b/packages/busymax_android_platform/example/android/app/src/main/AndroidManifest.xml new file mode 100644 index 00000000..00dee3fb --- /dev/null +++ b/packages/busymax_android_platform/example/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/busymax_android_platform/example/android/app/src/main/kotlin/io/busystack/busymax_android_platform_example/MainActivity.kt b/packages/busymax_android_platform/example/android/app/src/main/kotlin/io/busystack/busymax_android_platform_example/MainActivity.kt new file mode 100644 index 00000000..ffe9cc61 --- /dev/null +++ b/packages/busymax_android_platform/example/android/app/src/main/kotlin/io/busystack/busymax_android_platform_example/MainActivity.kt @@ -0,0 +1,5 @@ +package io.busystack.busymax_android_platform_example + +import io.flutter.embedding.android.FlutterActivity + +class MainActivity : FlutterActivity() diff --git a/packages/busymax_android_platform/example/android/app/src/main/res/drawable-v21/launch_background.xml b/packages/busymax_android_platform/example/android/app/src/main/res/drawable-v21/launch_background.xml new file mode 100644 index 00000000..f74085f3 --- /dev/null +++ b/packages/busymax_android_platform/example/android/app/src/main/res/drawable-v21/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/packages/busymax_android_platform/example/android/app/src/main/res/drawable/launch_background.xml b/packages/busymax_android_platform/example/android/app/src/main/res/drawable/launch_background.xml new file mode 100644 index 00000000..304732f8 --- /dev/null +++ b/packages/busymax_android_platform/example/android/app/src/main/res/drawable/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/packages/busymax_android_platform/example/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/packages/busymax_android_platform/example/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..db77bb4b7b0906d62b1847e87f15cdcacf6a4f29 GIT binary patch literal 544 zcmeAS@N?(olHy`uVBq!ia0vp^9w5xY3?!3`olAj~WQl7;NpOBzNqJ&XDuZK6ep0G} zXKrG8YEWuoN@d~6R2!h8bpbvhu0Wd6uZuB!w&u2PAxD2eNXD>P5D~Wn-+_Wa#27Xc zC?Zj|6r#X(-D3u$NCt}(Ms06KgJ4FxJVv{GM)!I~&n8Bnc94O7-Hd)cjDZswgC;Qs zO=b+9!WcT8F?0rF7!Uys2bs@gozCP?z~o%U|N3vA*22NaGQG zlg@K`O_XuxvZ&Ks^m&R!`&1=spLvfx7oGDKDwpwW`#iqdw@AL`7MR}m`rwr|mZgU`8P7SBkL78fFf!WnuYWm$5Z0 zNXhDbCv&49sM544K|?c)WrFfiZvCi9h0O)B3Pgg&ebxsLQ05GG~ AQ2+n{ literal 0 HcmV?d00001 diff --git a/packages/busymax_android_platform/example/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/packages/busymax_android_platform/example/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..17987b79bb8a35cc66c3c1fd44f5a5526c1b78be GIT binary patch literal 442 zcmeAS@N?(olHy`uVBq!ia0vp^1|ZDA3?vioaBc-sk|nMYCBgY=CFO}lsSJ)O`AMk? zp1FzXsX?iUDV2pMQ*D5Xx&nMcT!A!W`0S9QKQy;}1Cl^CgaH=;G9cpY;r$Q>i*pfB zP2drbID<_#qf;rPZx^FqH)F_D#*k@@q03KywUtLX8Ua?`H+NMzkczFPK3lFz@i_kW%1NOn0|D2I9n9wzH8m|-tHjsw|9>@K=iMBhxvkv6m8Y-l zytQ?X=U+MF$@3 zt`~i=@j|6y)RWMK--}M|=T`o&^Ni>IoWKHEbBXz7?A@mgWoL>!*SXo`SZH-*HSdS+ yn*9;$7;m`l>wYBC5bq;=U}IMqLzqbYCidGC!)_gkIk_C@Uy!y&wkt5C($~2D>~)O*cj@FGjOCM)M>_ixfudOh)?xMu#Fs z#}Y=@YDTwOM)x{K_j*Q;dPdJ?Mz0n|pLRx{4n|)f>SXlmV)XB04CrSJn#dS5nK2lM zrZ9#~WelCp7&e13Y$jvaEXHskn$2V!!DN-nWS__6T*l;H&Fopn?A6HZ-6WRLFP=R` zqG+CE#d4|IbyAI+rJJ`&x9*T`+a=p|0O(+s{UBcyZdkhj=yS1>AirP+0R;mf2uMgM zC}@~JfByORAh4SyRgi&!(cja>F(l*O+nd+@4m$|6K6KDn_&uvCpV23&>G9HJp{xgg zoq1^2_p9@|WEo z*X_Uko@K)qYYv~>43eQGMdbiGbo>E~Q& zrYBH{QP^@Sti!`2)uG{irBBq@y*$B zi#&(U-*=fp74j)RyIw49+0MRPMRU)+a2r*PJ$L5roHt2$UjExCTZSbq%V!HeS7J$N zdG@vOZB4v_lF7Plrx+hxo7(fCV&}fHq)$ literal 0 HcmV?d00001 diff --git a/packages/busymax_android_platform/example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/packages/busymax_android_platform/example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..d5f1c8d34e7a88e3f88bea192c3a370d44689c3c GIT binary patch literal 1031 zcmeAS@N?(olHy`uVBq!ia0vp^6F``Q8Ax83A=Cw=BuiW)N`mv#O3D+9QW+dm@{>{( zJaZG%Q-e|yQz{EjrrIztFa`(sgt!6~Yi|1%a`XoT0ojZ}lNrNjb9xjc(B0U1_% zz5^97Xt*%oq$rQy4?0GKNfJ44uvxI)gC`h-NZ|&0-7(qS@?b!5r36oQ}zyZrNO3 zMO=Or+<~>+A&uN&E!^Sl+>xE!QC-|oJv`ApDhqC^EWD|@=#J`=d#Xzxs4ah}w&Jnc z$|q_opQ^2TrnVZ0o~wh<3t%W&flvYGe#$xqda2bR_R zvPYgMcHgjZ5nSA^lJr%;<&0do;O^tDDh~=pIxA#coaCY>&N%M2^tq^U%3DB@ynvKo}b?yu-bFc-u0JHzced$sg7S3zqI(2 z#Km{dPr7I=pQ5>FuK#)QwK?Y`E`B?nP+}U)I#c1+FM*1kNvWG|a(TpksZQ3B@sD~b zpQ2)*V*TdwjFOtHvV|;OsiDqHi=6%)o4b!)x$)%9pGTsE z-JL={-Ffv+T87W(Xpooq<`r*VzWQcgBN$$`u}f>-ZQI1BB8ykN*=e4rIsJx9>z}*o zo~|9I;xof literal 0 HcmV?d00001 diff --git a/packages/busymax_android_platform/example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/packages/busymax_android_platform/example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..4d6372eebdb28e45604e46eeda8dd24651419bc0 GIT binary patch literal 1443 zcmb`G{WsKk6vsdJTdFg%tJav9_E4vzrOaqkWF|A724Nly!y+?N9`YV6wZ}5(X(D_N(?!*n3`|_r0Hc?=PQw&*vnU?QTFY zB_MsH|!j$PP;I}?dppoE_gA(4uc!jV&0!l7_;&p2^pxNo>PEcNJv za5_RT$o2Mf!<+r?&EbHH6nMoTsDOa;mN(wv8RNsHpG)`^ymG-S5By8=l9iVXzN_eG%Xg2@Xeq76tTZ*dGh~Lo9vl;Zfs+W#BydUw zCkZ$o1LqWQO$FC9aKlLl*7x9^0q%0}$OMlp@Kk_jHXOjofdePND+j!A{q!8~Jn+s3 z?~~w@4?egS02}8NuulUA=L~QQfm;MzCGd)XhiftT;+zFO&JVyp2mBww?;QByS_1w! zrQlx%{^cMj0|Bo1FjwY@Q8?Hx0cIPF*@-ZRFpPc#bBw{5@tD(5%sClzIfl8WU~V#u zm5Q;_F!wa$BSpqhN>W@2De?TKWR*!ujY;Yylk_X5#~V!L*Gw~;$%4Q8~Mad z@`-kG?yb$a9cHIApZDVZ^U6Xkp<*4rU82O7%}0jjHlK{id@?-wpN*fCHXyXh(bLt* zPc}H-x0e4E&nQ>y%B-(EL=9}RyC%MyX=upHuFhAk&MLbsF0LP-q`XnH78@fT+pKPW zu72MW`|?8ht^tz$iC}ZwLp4tB;Q49K!QCF3@!iB1qOI=?w z7In!}F~ij(18UYUjnbmC!qKhPo%24?8U1x{7o(+?^Zu0Hx81|FuS?bJ0jgBhEMzf< zCgUq7r2OCB(`XkKcN-TL>u5y#dD6D!)5W?`O5)V^>jb)P)GBdy%t$uUMpf$SNV31$ zb||OojAbvMP?T@$h_ZiFLFVHDmbyMhJF|-_)HX3%m=CDI+ID$0^C>kzxprBW)hw(v zr!Gmda);ICoQyhV_oP5+C%?jcG8v+D@9f?Dk*!BxY}dazmrT@64UrP3hlslANK)bq z$67n83eh}OeW&SV@HG95P|bjfqJ7gw$e+`Hxo!4cx`jdK1bJ>YDSpGKLPZ^1cv$ek zIB?0S<#tX?SJCLWdMd{-ME?$hc7A$zBOdIJ)4!KcAwb=VMov)nK;9z>x~rfT1>dS+ zZ6#`2v@`jgbqq)P22H)Tx2CpmM^o1$B+xT6`(v%5xJ(?j#>Q$+rx_R|7TzDZe{J6q zG1*EcU%tE?!kO%^M;3aM6JN*LAKUVb^xz8-Pxo#jR5(-KBeLJvA@-gxNHx0M-ZJLl z;#JwQoh~9V?`UVo#}{6ka@II>++D@%KqGpMdlQ}?9E*wFcf5(#XQnP$Dk5~%iX^>f z%$y;?M0BLp{O3a(-4A?ewryHrrD%cx#Q^%KY1H zNre$ve+vceSLZcNY4U(RBX&)oZn*Py()h)XkE?PL$!bNb{N5FVI2Y%LKEm%yvpyTP z(1P?z~7YxD~Rf<(a@_y` literal 0 HcmV?d00001 diff --git a/packages/busymax_android_platform/example/android/app/src/main/res/values-night/styles.xml b/packages/busymax_android_platform/example/android/app/src/main/res/values-night/styles.xml new file mode 100644 index 00000000..06952be7 --- /dev/null +++ b/packages/busymax_android_platform/example/android/app/src/main/res/values-night/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/packages/busymax_android_platform/example/android/app/src/main/res/values/styles.xml b/packages/busymax_android_platform/example/android/app/src/main/res/values/styles.xml new file mode 100644 index 00000000..cb1ef880 --- /dev/null +++ b/packages/busymax_android_platform/example/android/app/src/main/res/values/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/packages/busymax_android_platform/example/android/app/src/profile/AndroidManifest.xml b/packages/busymax_android_platform/example/android/app/src/profile/AndroidManifest.xml new file mode 100644 index 00000000..399f6981 --- /dev/null +++ b/packages/busymax_android_platform/example/android/app/src/profile/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/packages/busymax_android_platform/example/android/build.gradle.kts b/packages/busymax_android_platform/example/android/build.gradle.kts new file mode 100644 index 00000000..dbee657b --- /dev/null +++ b/packages/busymax_android_platform/example/android/build.gradle.kts @@ -0,0 +1,24 @@ +allprojects { + repositories { + google() + mavenCentral() + } +} + +val newBuildDir: Directory = + rootProject.layout.buildDirectory + .dir("../../build") + .get() +rootProject.layout.buildDirectory.value(newBuildDir) + +subprojects { + val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name) + project.layout.buildDirectory.value(newSubprojectBuildDir) +} +subprojects { + project.evaluationDependsOn(":app") +} + +tasks.register("clean") { + delete(rootProject.layout.buildDirectory) +} diff --git a/packages/busymax_android_platform/example/android/gradle.properties b/packages/busymax_android_platform/example/android/gradle.properties new file mode 100644 index 00000000..e96108cf --- /dev/null +++ b/packages/busymax_android_platform/example/android/gradle.properties @@ -0,0 +1,6 @@ +org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError +android.useAndroidX=true +# This newDsl flag was added by the Flutter template +android.newDsl=false +# This builtInKotlin flag was added by the Flutter template +android.builtInKotlin=false diff --git a/packages/busymax_android_platform/example/android/gradle/wrapper/gradle-wrapper.properties b/packages/busymax_android_platform/example/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 00000000..a20f2c46 --- /dev/null +++ b/packages/busymax_android_platform/example/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-all.zip diff --git a/packages/busymax_android_platform/example/android/settings.gradle.kts b/packages/busymax_android_platform/example/android/settings.gradle.kts new file mode 100644 index 00000000..b28021a9 --- /dev/null +++ b/packages/busymax_android_platform/example/android/settings.gradle.kts @@ -0,0 +1,26 @@ +pluginManagement { + val flutterSdkPath = + run { + val properties = java.util.Properties() + file("local.properties").inputStream().use { properties.load(it) } + val flutterSdkPath = properties.getProperty("flutter.sdk") + require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" } + flutterSdkPath + } + + includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") + + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +plugins { + id("dev.flutter.flutter-plugin-loader") version "1.0.0" + id("com.android.application") version "9.1.0" apply false + id("org.jetbrains.kotlin.android") version "2.4.0" apply false +} + +include(":app") diff --git a/packages/busymax_android_platform/example/integration_test/plugin_integration_test.dart b/packages/busymax_android_platform/example/integration_test/plugin_integration_test.dart new file mode 100644 index 00000000..e9cac251 --- /dev/null +++ b/packages/busymax_android_platform/example/integration_test/plugin_integration_test.dart @@ -0,0 +1,21 @@ +// This is a basic Flutter integration test. +// +// Since integration tests run in a full Flutter application, they can interact +// with the host side of a plugin implementation, unlike Dart unit tests. +// +// For more information about Flutter integration tests, please see +// https://flutter.dev/to/integration-testing + +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; + +import 'package:busymax_android_platform/busymax_android_platform.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + testWidgets('queries current time zone', (WidgetTester tester) async { + final zone = await BusyMaxAndroidPlatform.instance.currentTimeZoneId(); + expect(zone, isNotEmpty); + }); +} diff --git a/packages/busymax_android_platform/example/lib/main.dart b/packages/busymax_android_platform/example/lib/main.dart new file mode 100644 index 00000000..334757a1 --- /dev/null +++ b/packages/busymax_android_platform/example/lib/main.dart @@ -0,0 +1,58 @@ +import 'package:flutter/material.dart'; + +import 'dart:async'; + +import 'package:busymax_android_platform/busymax_android_platform.dart'; + +void main() { + runApp(const MyApp()); +} + +class MyApp extends StatefulWidget { + const MyApp({super.key}); + + @override + State createState() => _MyAppState(); +} + +class _MyAppState extends State { + String _platformVersion = 'Unknown'; + + @override + void initState() { + super.initState(); + initPlatformState(); + } + + // Platform messages are asynchronous, so we initialize in an async method. + Future initPlatformState() async { + String platformVersion; + // Platform messages may fail, so we use a try/catch PlatformException. + // We also handle the message potentially returning null. + try { + platformVersion = await BusyMaxAndroidPlatform.instance + .currentTimeZoneId(); + } on Object { + platformVersion = 'Failed to query the local time zone.'; + } + + // If the widget was removed from the tree while the asynchronous platform + // message was in flight, we want to discard the reply rather than calling + // setState to update our non-existent appearance. + if (!mounted) return; + + setState(() { + _platformVersion = platformVersion; + }); + } + + @override + Widget build(BuildContext context) { + return MaterialApp( + home: Scaffold( + appBar: AppBar(title: const Text('Plugin example app')), + body: Center(child: Text('Running on: $_platformVersion\n')), + ), + ); + } +} diff --git a/packages/busymax_android_platform/example/pubspec.lock b/packages/busymax_android_platform/example/pubspec.lock new file mode 100644 index 00000000..5f25feda --- /dev/null +++ b/packages/busymax_android_platform/example/pubspec.lock @@ -0,0 +1,291 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + async: + dependency: transitive + description: + name: async + sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37 + url: "https://pub.dev" + source: hosted + version: "2.13.1" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + busymax_android_platform: + dependency: "direct main" + description: + path: ".." + relative: true + source: path + version: "0.0.1" + characters: + dependency: transitive + description: + name: characters + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b + url: "https://pub.dev" + source: hosted + version: "1.4.1" + clock: + dependency: transitive + description: + name: clock + sha256: e51d50bca3217c9a9fa2b41a30e4a38971133f5f9ec7a3d57bae095007f1d28e + url: "https://pub.dev" + source: hosted + version: "1.1.3" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + cupertino_icons: + dependency: "direct main" + description: + name: cupertino_icons + sha256: "41e005c33bd814be4d3096aff55b1908d419fde52ca656c8c47719ec745873cd" + url: "https://pub.dev" + source: hosted + version: "1.0.9" + fake_async: + dependency: transitive + description: + name: fake_async + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" + url: "https://pub.dev" + source: hosted + version: "1.3.3" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_driver: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + flutter_lints: + dependency: "direct dev" + description: + name: flutter_lints + sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1" + url: "https://pub.dev" + source: hosted + version: "6.0.0" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + fuchsia_remote_debug_protocol: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + integration_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" + url: "https://pub.dev" + source: hosted + version: "11.0.2" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" + url: "https://pub.dev" + source: hosted + version: "3.0.10" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + lints: + dependency: transitive + description: + name: lints + sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df" + url: "https://pub.dev" + source: hosted + version: "6.1.0" + matcher: + dependency: transitive + description: + name: matcher + sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd" + url: "https://pub.dev" + source: hosted + version: "0.12.20" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" + url: "https://pub.dev" + source: hosted + version: "0.13.0" + meta: + dependency: transitive + description: + name: meta + sha256: "307249ce4ff29d58a18e97f6345f539382eb9c9c29ecda628900f31de0443dd9" + url: "https://pub.dev" + source: hosted + version: "1.19.0" + path: + dependency: transitive + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + platform: + dependency: transitive + description: + name: platform + sha256: a36d119c13416516a7b5913fbe8af8531e11633d784c550b2125f76c758524ec + url: "https://pub.dev" + source: hosted + version: "3.2.0" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.dev" + source: hosted + version: "2.1.8" + process: + dependency: transitive + description: + name: process + sha256: "4242ba3508d37e01808bdf71ad1d5bb93a8d671bf2e7450e6b1b353fb0808891" + url: "https://pub.dev" + source: hosted + version: "5.0.6" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + source_span: + dependency: transitive + description: + name: source_span + sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" + url: "https://pub.dev" + source: hosted + version: "1.10.2" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "277654b3034d17ac6f9f1cb5595db011b1d5d41e8806866db28e0abaa101c490" + url: "https://pub.dev" + source: hosted + version: "1.12.2" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + sync_http: + dependency: transitive + description: + name: sync_http + sha256: "7f0cd72eca000d2e026bcd6f990b81d0ca06022ef4e32fb257b30d3d1014a961" + url: "https://pub.dev" + source: hosted + version: "0.3.1" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + test_api: + dependency: transitive + description: + name: test_api + sha256: "2a122cbe059f8b610d3a5415f42e255b6c17b1f21eee1d960f31080237fb4f11" + url: "https://pub.dev" + source: hosted + version: "0.7.12" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: f36f9f3be64c6198714492bb455c11056e33e2f85d9a0b676a48301e44fdcf47 + url: "https://pub.dev" + source: hosted + version: "2.4.2" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "5f37239c4851efcef929cea7824e76df7f2f0970aef85d66bbc430afa40e72f0" + url: "https://pub.dev" + source: hosted + version: "15.3.0" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + webdriver: + dependency: transitive + description: + name: webdriver + sha256: "28b82ec894fed45dd71c23ba62d1af973ed97dd59a4f5790a4d38b0b13e5657e" + url: "https://pub.dev" + source: hosted + version: "3.2.0" +sdks: + dart: ">=3.13.2 <4.0.0" + flutter: ">=3.18.0-18.0.pre.54" diff --git a/packages/busymax_android_platform/example/pubspec.yaml b/packages/busymax_android_platform/example/pubspec.yaml new file mode 100644 index 00000000..8f90e2a6 --- /dev/null +++ b/packages/busymax_android_platform/example/pubspec.yaml @@ -0,0 +1,85 @@ +name: busymax_android_platform_example +description: "Demonstrates how to use the busymax_android_platform plugin." +# The following line prevents the package from being accidentally published to +# pub.dev using `flutter pub publish`. This is preferred for private packages. +publish_to: 'none' # Remove this line if you wish to publish to pub.dev + +environment: + sdk: ^3.13.2 + +# Dependencies specify other packages that your package needs in order to work. +# To automatically upgrade your package dependencies to the latest versions +# consider running `flutter pub upgrade --major-versions`. Alternatively, +# dependencies can be manually updated by changing the version numbers below to +# the latest version available on pub.dev. To see which dependencies have newer +# versions available, run `flutter pub outdated`. +dependencies: + flutter: + sdk: flutter + + busymax_android_platform: + # When depending on this package from a real application you should use: + # busymax_android_platform: ^x.y.z + # See https://dart.dev/tools/pub/dependencies#version-constraints + # The example app is bundled with the plugin so we use a path dependency on + # the parent directory to use the current plugin's version. + path: ../ + + # The following adds the Cupertino Icons font to your application. + # Use with the CupertinoIcons class for iOS style icons. + cupertino_icons: ^1.0.8 + +dev_dependencies: + integration_test: + sdk: flutter + flutter_test: + sdk: flutter + + # The "flutter_lints" package below contains a set of recommended lints to + # encourage good coding practices. The lint set provided by the package is + # activated in the `analysis_options.yaml` file located at the root of your + # package. See that file for information about deactivating specific lint + # rules and activating additional ones. + flutter_lints: ^6.0.0 + +# For information on the generic Dart part of this file, see the +# following page: https://dart.dev/tools/pub/pubspec + +# The following section is specific to Flutter packages. +flutter: + + # The following line ensures that the Material Icons font is + # included with your application, so that you can use the icons in + # the material Icons class. + uses-material-design: true + + # To add assets to your application, add an assets section, like this: + # assets: + # - images/a_dot_burr.jpeg + # - images/a_dot_ham.jpeg + + # An image asset can refer to one or more resolution-specific "variants", see + # https://flutter.dev/to/resolution-aware-images + + # For details regarding adding assets from package dependencies, see + # https://flutter.dev/to/asset-from-package + + # To add custom fonts to your application, add a fonts section here, + # in this "flutter" section. Each entry in this list should have a + # "family" key with the font family name, and a "fonts" key with a + # list giving the asset and other descriptors for the font. For + # example: + # fonts: + # - family: Schyler + # fonts: + # - asset: fonts/Schyler-Regular.ttf + # - asset: fonts/Schyler-Italic.ttf + # style: italic + # - family: Trajan Pro + # fonts: + # - asset: fonts/TrajanPro.ttf + # - asset: fonts/TrajanPro_Bold.ttf + # weight: 700 + # + # For details regarding fonts from package dependencies, + # see https://flutter.dev/to/font-from-package diff --git a/packages/busymax_android_platform/example/test/widget_test.dart b/packages/busymax_android_platform/example/test/widget_test.dart new file mode 100644 index 00000000..ee4e52a7 --- /dev/null +++ b/packages/busymax_android_platform/example/test/widget_test.dart @@ -0,0 +1,27 @@ +// This is a basic Flutter widget test. +// +// To perform an interaction with a widget in your test, use the WidgetTester +// utility in the flutter_test package. For example, you can send tap and scroll +// gestures. You can also use WidgetTester to find child widgets in the widget +// tree, read text, and verify that the values of widget properties are correct. + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:busymax_android_platform_example/main.dart'; + +void main() { + testWidgets('Verify Platform version', (WidgetTester tester) async { + // Build our app and trigger a frame. + await tester.pumpWidget(const MyApp()); + + // Verify that platform version is retrieved. + expect( + find.byWidgetPredicate( + (Widget widget) => + widget is Text && widget.data!.startsWith('Running on:'), + ), + findsOneWidget, + ); + }); +} diff --git a/packages/busymax_android_platform/lib/busymax_android_platform.dart b/packages/busymax_android_platform/lib/busymax_android_platform.dart new file mode 100644 index 00000000..8f4bae6a --- /dev/null +++ b/packages/busymax_android_platform/lib/busymax_android_platform.dart @@ -0,0 +1,325 @@ +import 'dart:async'; + +import 'package:flutter/services.dart'; + +/// Narrow Android API surface owned by BusyMax. +/// +/// Interactive methods fail with `android/activity-unavailable` in a headless +/// engine. Silent authorization, clock queries, and operation gates are safe +/// without an attached Activity. +final class BusyMaxAndroidPlatform { + BusyMaxAndroidPlatform({MethodChannel? methodChannel, EventChannel? events}) + : _methodChannel = + methodChannel ?? const MethodChannel('io.busystack.busymax/android'), + _events = events ?? const EventChannel('io.busystack.busymax/events'); + + static final BusyMaxAndroidPlatform instance = BusyMaxAndroidPlatform(); + + final MethodChannel _methodChannel; + final EventChannel _events; + + Stream? _eventStream; + + Stream get events => _eventStream ??= _events + .receiveBroadcastStream() + .map((value) => AndroidPlatformEvent.fromMap(_stringMap(value))) + .asBroadcastStream(); + + Future currentTimeZoneId() async => + await _methodChannel.invokeMethod('currentTimeZoneId') ?? + 'Etc/UTC'; + + Future uses24HourFormat() async => + await _methodChannel.invokeMethod('uses24HourFormat') ?? false; + + Future googleAuthorizationAvailable() async => + await _methodChannel.invokeMethod('googleAuthorizationAvailable') ?? + false; + + Future microsoftAuthorizationAvailable() async => + await _methodChannel.invokeMethod( + 'microsoftAuthorizationAvailable', + ) ?? + false; + + Future authorizeGoogleInteractively({ + required List scopes, + }) async => AndroidAuthorizationToken.fromMap( + _stringMap( + await _methodChannel.invokeMethod('authorizeGoogleInteractive', { + 'scopes': scopes, + }), + ), + ); + + Future authorizeGoogleSilently({ + required String accountId, + required List scopes, + }) async => AndroidAuthorizationToken.fromMap( + _stringMap( + await _methodChannel.invokeMethod('authorizeGoogleSilent', { + 'accountId': accountId, + 'scopes': scopes, + }), + ), + ); + + Future authorizeMicrosoftInteractively({ + required List scopes, + }) async => AndroidAuthorizationToken.fromMap( + _stringMap( + await _methodChannel.invokeMethod( + 'authorizeMicrosoftInteractive', + {'scopes': scopes}, + ), + ), + ); + + Future authorizeMicrosoftSilently({ + required String accountId, + required List scopes, + }) async => AndroidAuthorizationToken.fromMap( + _stringMap( + await _methodChannel.invokeMethod('authorizeMicrosoftSilent', { + 'accountId': accountId, + 'scopes': scopes, + }), + ), + ); + + Future bindAuthorization({ + required String provider, + required String accountId, + required String nativeAccountId, + String? username, + String? authority, + }) { + final arguments = { + 'provider': provider, + 'accountId': accountId, + 'nativeAccountId': nativeAccountId, + }; + if (username != null) arguments['username'] = username; + if (authority != null) arguments['authority'] = authority; + return _methodChannel.invokeMethod('bindAuthorization', arguments); + } + + Future clearRejectedToken(String token) => _methodChannel + .invokeMethod('clearRejectedGoogleToken', {'token': token}); + + Future removeAuthorization({ + required String provider, + required String accountId, + bool revoke = false, + }) => _methodChannel.invokeMethod('removeAuthorization', { + 'provider': provider, + 'accountId': accountId, + 'revoke': revoke, + }); + + Future cancelInteractiveAuthorization() => + _methodChannel.invokeMethod('cancelInteractiveAuthorization'); + + Future openDocument({ + List mimeTypes = const ['text/calendar'], + int maximumBytes = 16 * 1024 * 1024, + }) async { + final value = await _methodChannel.invokeMethod('openDocument', { + 'mimeTypes': mimeTypes, + 'maximumBytes': maximumBytes, + }); + return value == null ? null : AndroidDocument.fromMap(_stringMap(value)); + } + + /// Reads a URI BusyMax received through ACTION_VIEW or ACTION_SEND. + /// + /// The native side deliberately accepts only content URIs and enforces the + /// same bound as the document picker; callers never treat the URI as a path. + Future readDocumentUri( + Uri uri, { + int maximumBytes = 16 * 1024 * 1024, + }) async => AndroidDocument.fromMap( + _stringMap( + await _methodChannel.invokeMethod('readDocumentUri', { + 'uri': uri.toString(), + 'maximumBytes': maximumBytes, + }), + ), + ); + + Future notifyDataChanged() => + _methodChannel.invokeMethod('notifyDataChanged'); + + Future createDocument({ + required String suggestedName, + required String mimeType, + required Uint8List bytes, + }) => _methodChannel.invokeMethod('createDocument', { + 'suggestedName': suggestedName, + 'mimeType': mimeType, + 'bytes': bytes, + }); + + Future exportDocumentTree({ + required String folderName, + required List resources, + }) => _methodChannel.invokeMethod('exportDocumentTree', { + 'folderName': folderName, + 'resources': [for (final resource in resources) resource.toMap()], + }); + + Future launchExternalUri(Uri uri) async => + await _methodChannel.invokeMethod('launchExternalUri', { + 'uri': uri.toString(), + }) ?? + false; + + Future hasLocalNetworkAccess() async => + await _methodChannel.invokeMethod('hasLocalNetworkAccess') ?? true; + + Future requestLocalNetworkAccess() async => + await _methodChannel.invokeMethod('requestLocalNetworkAccess') ?? + false; + + Future openNotificationSettings() => + _methodChannel.invokeMethod('openNotificationSettings'); + + Future acquireAccountGate( + String accountId, { + Duration timeout = const Duration(seconds: 30), + }) async => (await _methodChannel.invokeMethod('acquireAccountGate', { + 'accountId': accountId, + 'timeoutMillis': timeout.inMilliseconds, + }))!; + + Future releaseAccountGate(String leaseId) => _methodChannel + .invokeMethod('releaseAccountGate', {'leaseId': leaseId}); + + Future takeInitialActivation() async { + final value = await _methodChannel.invokeMethod( + 'takeInitialActivation', + ); + return value == null ? null : AndroidActivation.fromMap(_stringMap(value)); + } +} + +final class AndroidAuthorizationToken { + const AndroidAuthorizationToken({ + required this.accessToken, + required this.scopes, + required this.nativeAccountId, + this.username, + this.authority, + this.expiresAtUtc, + }); + + factory AndroidAuthorizationToken.fromMap(Map map) { + final token = map['accessToken']?.toString() ?? ''; + final nativeAccountId = map['nativeAccountId']?.toString() ?? ''; + if (token.isEmpty || nativeAccountId.isEmpty) { + throw const FormatException( + 'Android authorization result is incomplete.', + ); + } + return AndroidAuthorizationToken( + accessToken: token, + scopes: [ + for (final scope in map['scopes'] as List? ?? const []) '$scope', + ], + nativeAccountId: nativeAccountId, + username: map['username']?.toString(), + authority: map['authority']?.toString(), + expiresAtUtc: switch (map['expiresAtEpochMillis']) { + final int value => DateTime.fromMillisecondsSinceEpoch( + value, + isUtc: true, + ), + _ => null, + }, + ); + } + + final String accessToken; + final List scopes; + final String nativeAccountId; + final String? username; + final String? authority; + final DateTime? expiresAtUtc; +} + +final class AndroidDocument { + const AndroidDocument({ + required this.uri, + required this.bytes, + this.name, + this.mimeType, + }); + + factory AndroidDocument.fromMap(Map map) => AndroidDocument( + uri: Uri.parse(map['uri']! as String), + bytes: map['bytes']! as Uint8List, + name: map['name']?.toString(), + mimeType: map['mimeType']?.toString(), + ); + + final Uri uri; + final Uint8List bytes; + final String? name; + final String? mimeType; +} + +final class AndroidExportResource { + const AndroidExportResource({required this.name, required this.bytes}); + + final String name; + final Uint8List bytes; + + Map toMap() => {'name': name, 'bytes': bytes}; +} + +final class AndroidActivation { + const AndroidActivation({ + required this.kind, + this.uri, + this.mimeType, + this.action, + this.payload, + }); + + factory AndroidActivation.fromMap(Map map) => + AndroidActivation( + kind: map['kind']?.toString() ?? 'unknown', + uri: switch (map['uri']) { + final String value when value.isNotEmpty => Uri.tryParse(value), + _ => null, + }, + mimeType: map['mimeType']?.toString(), + action: map['action']?.toString(), + payload: map['payload']?.toString(), + ); + + final String kind; + final Uri? uri; + final String? mimeType; + final String? action; + final String? payload; +} + +final class AndroidPlatformEvent { + const AndroidPlatformEvent(this.type, this.data); + + factory AndroidPlatformEvent.fromMap(Map map) => + AndroidPlatformEvent( + map['type']?.toString() ?? map['kind']?.toString() ?? 'unknown', + map, + ); + + final String type; + final Map data; +} + +Map _stringMap(Object? value) { + if (value is Map) return value; + if (value is Map) return value.cast(); + throw const FormatException('Android platform response is not a map.'); +} diff --git a/packages/busymax_android_platform/pubspec.yaml b/packages/busymax_android_platform/pubspec.yaml new file mode 100644 index 00000000..065065fa --- /dev/null +++ b/packages/busymax_android_platform/pubspec.yaml @@ -0,0 +1,69 @@ +name: busymax_android_platform +description: BusyMax-owned Android bridge for authorization, documents, clock, and cross-engine coordination. +version: 0.1.0 +repository: https://github.com/BusyStack/BusyMax + +environment: + sdk: ^3.12.0 + flutter: '>=3.47.2' + +dependencies: + flutter: + sdk: flutter + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^6.0.0 + +# For information on the generic Dart part of this file, see the +# following page: https://dart.dev/tools/pub/pubspec + +# The following section is specific to Flutter packages. +flutter: + # This section identifies this Flutter project as a plugin project. + # The 'pluginClass' specifies the class (in Java, Kotlin, Swift, Objective-C, etc.) + # which should be registered in the plugin registry. This is required for + # using method channels. + # The Android 'package' specifies package in which the registered class is. + # This is required for using method channels on Android. + # The 'ffiPlugin' specifies that native code should be built and bundled. + # This is required for using `dart:ffi`. + # All these are used by the tooling to maintain consistency when + # adding or updating assets for this project. + plugin: + platforms: + android: + package: io.busystack.busymax_android_platform + pluginClass: BusymaxAndroidPlatformPlugin + + # To add assets to your plugin package, add an assets section, like this: + # assets: + # - images/a_dot_burr.jpeg + # - images/a_dot_ham.jpeg + # + # For details regarding assets in packages, see + # https://flutter.dev/to/asset-from-package + # + # An image asset can refer to one or more resolution-specific "variants", see + # https://flutter.dev/to/resolution-aware-images + + # To add custom fonts to your plugin package, add a fonts section here, + # in this "flutter" section. Each entry in this list should have a + # "family" key with the font family name, and a "fonts" key with a + # list giving the asset and other descriptors for the font. For + # example: + # fonts: + # - family: Schyler + # fonts: + # - asset: fonts/Schyler-Regular.ttf + # - asset: fonts/Schyler-Italic.ttf + # style: italic + # - family: Trajan Pro + # fonts: + # - asset: fonts/TrajanPro.ttf + # - asset: fonts/TrajanPro_Bold.ttf + # weight: 700 + # + # For details regarding fonts in packages, see + # https://flutter.dev/to/font-from-package diff --git a/pubspec.lock b/pubspec.lock index cb6831e1..203e712c 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -5,18 +5,18 @@ packages: dependency: transitive description: name: _fe_analyzer_shared - sha256: "8d7ff3948166b8ec5da0fbb5962000926b8e02f2ed9b3e51d1738905fbd4c98d" + sha256: fdcd9f70f9eb80df3bc5ed0fa67280df2595fd481948df8a9ab082e6a40ad04b url: "https://pub.dev" source: hosted - version: "93.0.0" + version: "108.0.0" analyzer: dependency: transitive description: name: analyzer - sha256: de7148ed2fcec579b19f122c1800933dfa028f6d9fd38a152b04b1516cec120b + sha256: a51c769bff3b6dfbe9d60199b8606d808290702a296bef0c26a4ca391d414e46 url: "https://pub.dev" source: hosted - version: "10.0.1" + version: "14.4.0" animated_vector: dependency: transitive description: @@ -37,10 +37,10 @@ packages: dependency: transitive description: name: archive - sha256: a96e8b390886ee8abb49b7bd3ac8df6f451c621619f52a26e815fdcf568959ff + sha256: "6c5bcd986e06b94e3c40244af471750840a3d2341d1f9763a1100a14add517b4" url: "https://pub.dev" source: hosted - version: "4.0.9" + version: "4.3.0" args: dependency: transitive description: @@ -77,34 +77,34 @@ packages: dependency: transitive description: name: build - sha256: a156715e7cd728130c592f30552575908aae5b100005fbc1f0fb16b3c03a3d10 + sha256: "8a5c5761af8e31748bba3c82f68925ace40f3225c3eea25be9beb57eca7cd7a8" url: "https://pub.dev" source: hosted - version: "4.0.6" + version: "4.0.11" build_config: dependency: transitive description: name: build_config - sha256: "4070d2a59f8eec34c97c86ceb44403834899075f66e8a9d59706f8e7834f6f71" + sha256: d466ed2dc9c6cd1d169948879b84ee061eb5e22c64a7c6089879c6296d272a8d url: "https://pub.dev" source: hosted - version: "1.3.0" + version: "1.3.3" build_daemon: dependency: transitive description: name: build_daemon - sha256: bf05f6e12cfea92d3c09308d7bcdab1906cd8a179b023269eed00c071004b957 + sha256: e1d40ef3f7934986d5da2271b1ba07794921ce263e44d622fb6c406d76589e33 url: "https://pub.dev" source: hosted - version: "4.1.1" + version: "4.1.6" build_runner: dependency: "direct dev" description: name: build_runner - sha256: "1523ce62448ebac2c15a8ba5fbad8acac169788658a7dd2a1c2d9c2a9318b9a6" + sha256: "894c243f6bc32015fec466ce30a6925bd537a77a426ee5bf481120477eb3de67" url: "https://pub.dev" source: hosted - version: "2.15.0" + version: "2.16.1" built_collection: dependency: transitive description: @@ -117,10 +117,17 @@ packages: dependency: transitive description: name: built_value - sha256: "34e4067d30ce212937df995f03b69992eea683539ceeac7f679a1f1eba055b56" + sha256: f87ea98192116f7093cb214551ce1929caae0681fdba282b3d8b4462adee7bb7 url: "https://pub.dev" source: hosted - version: "8.12.6" + version: "8.13.0" + busymax_android_platform: + dependency: "direct main" + description: + path: "packages/busymax_android_platform" + relative: true + source: path + version: "0.1.0" characters: dependency: transitive description: @@ -149,18 +156,18 @@ packages: dependency: transitive description: name: cli_util - sha256: ff6785f7e9e3c38ac98b2fb035701789de90154024a75b6cb926445e83197d1c + sha256: "71e43f1976c3eae2d9468a5b4d6600bf8cae61508b87f27fa1799228935d48ab" url: "https://pub.dev" source: hosted - version: "0.4.2" + version: "0.5.2" clock: dependency: transitive description: name: clock - sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b + sha256: e51d50bca3217c9a9fa2b41a30e4a38971133f5f9ec7a3d57bae095007f1d28e url: "https://pub.dev" source: hosted - version: "1.1.2" + version: "1.1.3" code_assets: dependency: transitive description: @@ -181,10 +188,10 @@ packages: dependency: "direct main" description: name: connectivity_plus - sha256: "62ffa266d9a23b79fb3fcbc206afc00bb979417ba57b1324c546b5aab95ba057" + sha256: "762c99f890ca8bf87f7337236f99edd42793843bc6c3631da294a76653a54bd0" url: "https://pub.dev" source: hosted - version: "7.1.1" + version: "7.3.1" connectivity_plus_platform_interface: dependency: transitive description: @@ -213,10 +220,10 @@ packages: dependency: transitive description: name: cross_file - sha256: "28bb3ae56f117b5aec029d702a90f57d285cd975c3c5c281eaca38dbc47c5937" + sha256: f141ea4f277af142a0356955707f6556f37b03947d39d55585981a06ca437bd6 url: "https://pub.dev" source: hosted - version: "0.3.5+2" + version: "0.3.5+5" crypto: dependency: "direct main" description: @@ -237,18 +244,18 @@ packages: dependency: transitive description: name: dart_style - sha256: "29f7ecc274a86d32920b1d9cfc7502fa87220da41ec60b55f329559d5732e2b2" + sha256: "82ade9fc4273f29ed673e33166944465225b4f7fc5d4aaef48605cc751c18fc1" url: "https://pub.dev" source: hosted - version: "3.1.7" + version: "3.1.13" dbus: dependency: "direct main" description: name: dbus - sha256: "0ce9b0a839e6dee59a37a623d2fc26a35bbbe6404213e419b0d6411023d62645" + sha256: a48d5da28e89bd02196e80d81ed8d7954923d00a0f4a68cc20b575038f023383 url: "https://pub.dev" source: hosted - version: "0.7.14" + version: "0.7.15" desktop_notifications: dependency: "direct main" description: @@ -269,18 +276,18 @@ packages: dependency: "direct main" description: name: drift - sha256: "8033500116b24398fba0cca0369cc31678cd627c01e41753a61186911cea743e" + sha256: e3b6352aa02e60bed6ffc3cc9894c421578dad5fbef21b1f7f57cdc0e4edabcb url: "https://pub.dev" source: hosted - version: "2.33.0" + version: "2.35.0" drift_dev: dependency: "direct dev" description: name: drift_dev - sha256: b3dd5b75e30522a91da8abda9f5bb17230cb038097f6d15fa75d42bb563428aa + sha256: fbad5c1e3a73820cdb1f32161eb2f40bb5e5f03c0c1a3d236595725aec7efc6d url: "https://pub.dev" source: hosted - version: "2.33.0" + version: "2.35.0" equatable: dependency: transitive description: @@ -333,34 +340,34 @@ packages: dependency: transitive description: name: file_selector_android - sha256: "6a26687fa65cbc28a5345c7ae6f227e89f0b47740978a4c475b1a625da7a331b" + sha256: d670cd0ce77a2e785b18d8b4d0a8d6a222d6a813ec9b7ddf2790a1b4fb6fa92c url: "https://pub.dev" source: hosted - version: "0.5.2+8" + version: "0.5.2+11" file_selector_ios: dependency: transitive description: name: file_selector_ios - sha256: e2ecf2885c121691ce13b60db3508f53c01f869fb6e8dc5c1cfa771e4c46aeca + sha256: "97269e5307a0ab813b1fa2430bada0a96e0afb74848417f8676f64ba5de0051c" url: "https://pub.dev" source: hosted - version: "0.5.3+5" + version: "0.5.3+6" file_selector_linux: dependency: transitive description: name: file_selector_linux - sha256: "2567f398e06ac72dcf2e98a0c95df2a9edd03c2c2e0cacd4780f20cdf56263a0" + sha256: da76400e7872ce7637ffdce12749ec24169c25f6195c28372208e65a24bcd2ab url: "https://pub.dev" source: hosted - version: "0.9.4" + version: "0.9.4+1" file_selector_macos: dependency: transitive description: name: file_selector_macos - sha256: "5e0bbe9c312416f1787a68259ea1505b52f258c587f12920422671807c4d618a" + sha256: d57c62362766b5e7ae739448650b66c6aab7a68ba7ecc65e04018652645ae0f4 url: "https://pub.dev" source: hosted - version: "0.9.5" + version: "0.9.5+1" file_selector_platform_interface: dependency: transitive description: @@ -381,10 +388,10 @@ packages: dependency: transitive description: name: file_selector_windows - sha256: "62197474ae75893a62df75939c777763d39c2bc5f73ce5b88497208bc269abfd" + sha256: fbefc5fb92c6d3cbe8d284a2cd971b593bb07d2cd6da8557b81a862250b4acec url: "https://pub.dev" source: hosted - version: "0.9.3+5" + version: "0.9.3+6" fixnum: dependency: transitive description: @@ -413,6 +420,11 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_driver: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" flutter_lints: dependency: "direct dev" description: @@ -478,10 +490,10 @@ packages: dependency: "direct main" description: name: flutter_secure_storage - sha256: "7686b1d6a29985dcbb808c59518226e603e3bfa7c0ddfd1a0d00e4cda77c868e" + sha256: fe638107c5f69119156ada2db5a57734385fac3f64430bd7252a00d3ead2ca4b url: "https://pub.dev" source: hosted - version: "10.3.1" + version: "10.3.4" flutter_secure_storage_darwin: dependency: transitive description: @@ -494,18 +506,18 @@ packages: dependency: transitive description: name: flutter_secure_storage_linux - sha256: a5f35ddab43cf5c8215d2feb4ce1957851f28c5c37e6f04335066a0602087bf5 + sha256: caa75bd78f017422912e3a904933113b2428eb79f981ac37acd2dd2a700458ea url: "https://pub.dev" source: hosted - version: "3.0.1" + version: "3.0.3" flutter_secure_storage_platform_interface: dependency: transitive description: name: flutter_secure_storage_platform_interface - sha256: "8ceea1223bee3c6ac1a22dabd8feefc550e4729b3675de4b5900f55afcb435d6" + sha256: "4bc033841169d07f690d46d89dbc3f5305b6562820822445384c82e0866e2719" url: "https://pub.dev" source: hosted - version: "2.0.1" + version: "2.1.0" flutter_secure_storage_web: dependency: transitive description: @@ -539,6 +551,11 @@ packages: description: flutter source: sdk version: "0.0.0" + fuchsia_remote_debug_protocol: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" get_it: dependency: transitive description: @@ -551,18 +568,18 @@ packages: dependency: transitive description: name: glob - sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de + sha256: "218aeb56050c714f62a3182775320dfa04602b55074873e24e31bbd39bda96fb" url: "https://pub.dev" source: hosted - version: "2.1.3" + version: "2.2.0" go_router: dependency: "direct main" description: name: go_router - sha256: "5922b2861e2235a3504896f0d6fa07d84141b480cf52eecd2f42cd25585a9e8a" + sha256: d7a3576cb312649eaa51f2356450aed686085fb58fcdebda5b359aa951eef7ea url: "https://pub.dev" source: hosted - version: "17.3.0" + version: "17.5.0" graphs: dependency: transitive description: @@ -631,10 +648,15 @@ packages: dependency: "direct main" description: name: infinite_calendar_view - sha256: "50098720236c796e2ec0c4340a56db9a9d06f3ccb491313ed6acb8350a972638" + sha256: e4b6fefb3b30c5f6969c18f5dc5bac830bc9dd4d36d687f2dd786784cacba475 url: "https://pub.dev" source: hosted - version: "2.11.5" + version: "2.12.0" + integration_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" intl: dependency: "direct main" description: @@ -647,26 +669,34 @@ packages: dependency: transitive description: name: io - sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b + sha256: "2635216ca6a737e60de577ffa1a48a0bec76ca8a62917cfc1bb88c14c570646f" url: "https://pub.dev" source: hosted - version: "1.0.5" + version: "1.1.0" jni: dependency: transitive description: name: jni - sha256: c2230682d5bc2362c1c9e8d3c7f406d9cbba23ab3f2e203a025dd47e0fb2e68f + sha256: f038e58b4dc2c9037f50e233175086337e0b305e356d28211bf55f21c504cbd3 url: "https://pub.dev" source: hosted - version: "1.0.0" + version: "1.0.3" jni_flutter: dependency: transitive description: name: jni_flutter - sha256: "8b59e590786050b1cd866677dddaf76b1ade5e7bc751abe04b86e84d379d3ba6" + sha256: b2310cdd4c18c65c081ab141a41efa94aa26c65431803703ece51996f174f351 url: "https://pub.dev" source: hosted - version: "1.0.1" + version: "1.0.3" + jni_util: + dependency: transitive + description: + name: jni_util + sha256: "1ba86da04a5f2bf18fde2edb235587e70c5b0fc5bd4ba955f46b00942c3fc35f" + url: "https://pub.dev" + source: hosted + version: "1.0.0" json_annotation: dependency: transitive description: @@ -759,18 +789,18 @@ packages: dependency: transitive description: name: meta - sha256: "307249ce4ff29d58a18e97f6345f539382eb9c9c29ecda628900f31de0443dd9" + sha256: c82594181e3312f3d0695fc95aaaf7758d75b8d4ae2bbecf223b9fd5109a059d url: "https://pub.dev" source: hosted - version: "1.19.0" + version: "1.18.3" mime: dependency: transitive description: name: mime - sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" + sha256: bd47de35f07e27267e69c8c8b22edf9473bfee170a60d60fcc93730c5144b7f6 url: "https://pub.dev" source: hosted - version: "2.0.0" + version: "2.1.0" mocktail: dependency: "direct dev" description: @@ -791,10 +821,10 @@ packages: dependency: transitive description: name: native_toolchain_c - sha256: f59351d28f49520cd3a74eb1f41c5f19ae15e53c65a3231d14af672e46510a96 + sha256: f9c168717100ae6d9fee9ffb0be379bf1f8b26b0f6bcbd4fdddcd931993a6a72 url: "https://pub.dev" source: hosted - version: "0.19.1" + version: "0.19.2" nm: dependency: transitive description: @@ -807,26 +837,26 @@ packages: dependency: transitive description: name: objective_c - sha256: "6cb691c686fa2838c6deb34980d426145c2a5d537491cb83d463c33cdbc726ed" + sha256: b7fb95a6d9a4f009edd63dc5ac69f07420b23a16161c6dd8660290b59c602e8e url: "https://pub.dev" source: hosted - version: "9.4.1" + version: "9.5.0" package_config: dependency: transitive description: name: package_config - sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc + sha256: ffcf4cf3d6c0b74ac43708d9f56625506e8a68aa935abe9d267a7330f320eb5d url: "https://pub.dev" source: hosted - version: "2.2.0" + version: "3.0.0" package_info_plus: dependency: "direct main" description: name: package_info_plus - sha256: "4bf625947f6c7713ee242296a682e23e44823c09cf9d79e4f1238923c92db852" + sha256: "127e1751e37ffb2ff4658beeaca77bad0c27bf5f932bd3a501c2296926d4b481" url: "https://pub.dev" source: hosted - version: "10.1.0" + version: "10.2.1" package_info_plus_platform_interface: dependency: transitive description: @@ -855,10 +885,10 @@ packages: dependency: "direct main" description: name: path_provider - sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" + sha256: a7f4874f987173da295a61c181b8ee71dab59b332a486b391babf26a1b884825 url: "https://pub.dev" source: hosted - version: "2.1.5" + version: "2.1.6" path_provider_android: dependency: transitive description: @@ -879,18 +909,18 @@ packages: dependency: transitive description: name: path_provider_linux - sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279 + sha256: "58c2005f147315b11e9b4a7bc889cd5203e250cba8e3f012dae259b4972b5c16" url: "https://pub.dev" source: hosted - version: "2.2.1" + version: "2.2.2" path_provider_platform_interface: dependency: transitive description: name: path_provider_platform_interface - sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334" + sha256: "484838772624c3a4b94f1e44a3e19897fee738f2d5c4ce448443b0417f7c9dda" url: "https://pub.dev" source: hosted - version: "2.1.2" + version: "2.1.3" path_provider_windows: dependency: transitive description: @@ -911,10 +941,10 @@ packages: dependency: transitive description: name: platform - sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" + sha256: a36d119c13416516a7b5913fbe8af8531e11633d784c550b2125f76c758524ec url: "https://pub.dev" source: hosted - version: "3.1.6" + version: "3.2.0" platform_linux: dependency: transitive description: @@ -935,34 +965,42 @@ packages: dependency: transitive description: name: pool - sha256: "978783255c543aa3586a1b3c21f6e9d720eb315376a915872c61ef8b5c20177d" + sha256: "4177f68c237ea2128d1bee66ac17b2ce05ba3dbaafcbdd54c5d40a39d0b6b11c" url: "https://pub.dev" source: hosted - version: "1.5.2" + version: "1.5.3" posix: dependency: "direct main" description: name: posix - sha256: "185ef7606574f789b40f289c233efa52e96dead518aed988e040a10737febb07" + sha256: bc1bad54ad2b735816e31f8d4600cfde6c7839975085ddfbca48b6c9f7c4044e + url: "https://pub.dev" + source: hosted + version: "6.5.2" + process: + dependency: transitive + description: + name: process + sha256: "4242ba3508d37e01808bdf71ad1d5bb93a8d671bf2e7450e6b1b353fb0808891" url: "https://pub.dev" source: hosted - version: "6.5.0" + version: "5.0.6" pub_semver: dependency: transitive description: name: pub_semver - sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" + sha256: "261236774e8b1d69cfc6b9eabbc96c40f25e7a2d6b171f3385d4f65d5734fb24" url: "https://pub.dev" source: hosted - version: "2.2.0" + version: "2.2.1" pubspec_parse: dependency: transitive description: name: pubspec_parse - sha256: "0560ba233314abbed0a48a2956f7f022cce7c3e1e73df540277da7544cad4082" + sha256: c38b81cbf34450b67e0265d73433569d12e34782e30ed769c9cc99c9d5f2e796 url: "https://pub.dev" source: hosted - version: "1.5.0" + version: "1.6.0" recase: dependency: transitive description: @@ -1028,10 +1066,10 @@ packages: dependency: transitive description: name: source_gen - sha256: ec37cc0e6694374cbef59ed79685572c870a54ede6fa30a3e420feb3adffea02 + sha256: "11b6047da8e4eb6c643ccac3d0fda3f9c9e11651695f7da24a85ade8aff15a44" url: "https://pub.dev" source: hosted - version: "4.2.3" + version: "4.3.0" source_span: dependency: transitive description: @@ -1044,10 +1082,10 @@ packages: dependency: "direct main" description: name: sqlite3 - sha256: "9488c7d2cdb1091c91cacf7e207cff81b28bff8e366f042bad3afe7d34afe189" + sha256: "4c7fe79840389aaeaf05fd093f795b631b5a98e2bd28d54e555c100f4a9c7a1c" url: "https://pub.dev" source: hosted - version: "3.3.2" + version: "3.5.2" sqlite3_flutter_libs: dependency: "direct main" description: @@ -1060,18 +1098,18 @@ packages: dependency: transitive description: name: sqlparser - sha256: ecdc06d4a7d79dcbc928d99afd2f7f5b0f98a637c46f89be83d911617f759978 + sha256: "772bb2f6f5bce0631a60f26b57d6e8882e1105c22345b05c163ee6ee5d7ba32d" url: "https://pub.dev" source: hosted - version: "0.44.4" + version: "0.45.0" stack_trace: dependency: transitive description: name: stack_trace - sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + sha256: "277654b3034d17ac6f9f1cb5595db011b1d5d41e8806866db28e0abaa101c490" url: "https://pub.dev" source: hosted - version: "1.12.1" + version: "1.12.2" state_notifier: dependency: transitive description: @@ -1092,10 +1130,10 @@ packages: dependency: transitive description: name: stream_transform - sha256: ad47125e588cfd37a9a7f86c7d6356dde8dfe89d071d293f80ca9e9273a33871 + sha256: a00e5f18bffc764f923e7dec1038527f7fe7a1791361a7117f0358193f13d53a url: "https://pub.dev" source: hosted - version: "2.1.1" + version: "2.1.2" string_scanner: dependency: transitive description: @@ -1104,6 +1142,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.1" + sync_http: + dependency: transitive + description: + name: sync_http + sha256: "7f0cd72eca000d2e026bcd6f990b81d0ca06022ef4e32fb257b30d3d1014a961" + url: "https://pub.dev" + source: hosted + version: "0.3.1" system_theme: dependency: "direct main" description: @@ -1163,10 +1209,10 @@ packages: dependency: "direct main" description: name: ubuntu_localizations - sha256: a2cdc05d00caf65025fbdca18d13cfe6ed47f55f0804c0ed0e38bf3d4daa3021 + sha256: "62c292f5d091f5a471fcff911ae1647aa764d27873a4a7b0e5a93dfbf4c7e00c" url: "https://pub.dev" source: hosted - version: "0.5.2+3" + version: "0.5.2+4" url_launcher: dependency: "direct main" description: @@ -1179,34 +1225,34 @@ packages: dependency: transitive description: name: url_launcher_android - sha256: "17bc677f0b301615530dd1d67e0a9828cafa2d0b6b6eae4cd3679b7eac4a273c" + sha256: "611e87fb320b70d1dd721dc46af89c98aceccea9b31fde49e084591414e0c610" url: "https://pub.dev" source: hosted - version: "6.3.30" + version: "6.3.33" url_launcher_ios: dependency: transitive description: name: url_launcher_ios - sha256: "580fe5dfb51671ae38191d316e027f6b76272b026370708c2d898799750a02b0" + sha256: "8faa1aab294f1ab4040b43660c887b0418d5fa4f0cffef76a484e6aa1092eb4a" url: "https://pub.dev" source: hosted - version: "6.4.1" + version: "6.4.2" url_launcher_linux: dependency: transitive description: name: url_launcher_linux - sha256: d5e14138b3bc193a0f63c10a53c94b91d399df0512b1f29b94a043db7482384a + sha256: "10f86fef4c2c43563fa6c211ff9cf757adf4d3ab762c56bd430664a947d70cd0" url: "https://pub.dev" source: hosted - version: "3.2.2" + version: "3.2.3" url_launcher_macos: dependency: transitive description: name: url_launcher_macos - sha256: "368adf46f71ad3c21b8f06614adb38346f193f3a59ba8fe9a2fd74133070ba18" + sha256: "5e835a3b869c2d70325349c81c5a45c28e20791265b67b2669da6b08c5cd5201" url: "https://pub.dev" source: hosted - version: "3.2.5" + version: "3.2.6" url_launcher_platform_interface: dependency: transitive description: @@ -1227,18 +1273,18 @@ packages: dependency: transitive description: name: url_launcher_windows - sha256: "712c70ab1b99744ff066053cbe3e80c73332b38d46e5e945c98689b2e66fc15f" + sha256: "6c5ad3f22cd4c38e089b81963b3cd7bb83b111b2df5dce008bb066162f42e429" url: "https://pub.dev" source: hosted - version: "3.1.5" + version: "3.1.6" uuid: dependency: "direct main" description: name: uuid - sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489" + sha256: "9b129329f58692f6e6578329498a8fe9fbe98f090beb764ffbb8ee2eadd01dcd" url: "https://pub.dev" source: hosted - version: "4.5.3" + version: "4.6.0" vector_math: dependency: transitive description: @@ -1251,18 +1297,18 @@ packages: dependency: "direct dev" description: name: very_good_analysis - sha256: "27927d1140ce1b140f998b6340f730a626faa5b95110b3e34a238ff254d731d0" + sha256: "481af67ab5877af20325251dc215a4ebac7666a1c8cf09198ffd457bc612b33d" url: "https://pub.dev" source: hosted - version: "10.1.0" + version: "10.3.0" vm_service: dependency: transitive description: name: vm_service - sha256: "0016aef94fc66495ac78af5859181e3f3bf2026bd8eecc72b9565601e19ab360" + sha256: "5f37239c4851efcef929cea7824e76df7f2f0970aef85d66bbc430afa40e72f0" url: "https://pub.dev" source: hosted - version: "15.2.0" + version: "15.3.0" watcher: dependency: transitive description: @@ -1295,14 +1341,70 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.3" + webdriver: + dependency: transitive + description: + name: webdriver + sha256: "28b82ec894fed45dd71c23ba62d1af973ed97dd59a4f5790a4d38b0b13e5657e" + url: "https://pub.dev" + source: hosted + version: "3.2.0" win32: dependency: transitive description: name: win32 - sha256: ba6f4bba816c8d7e3c1580e170f3786d216951cc6b94babc3b814c08d2cb2738 + sha256: a0b93865d5644f11cf6a8c3f6db909f1ec168958b5805f6cc684adea957cd63d + url: "https://pub.dev" + source: hosted + version: "6.4.0" + workmanager: + dependency: "direct main" + description: + name: workmanager + sha256: "96b09a4a852ec487d29371f27031dab80ce2e260911e2dbe0f904951e22a3956" + url: "https://pub.dev" + source: hosted + version: "0.10.10" + workmanager_android: + dependency: transitive + description: + name: workmanager_android + sha256: ffe8aa061885370c9363f7ae2d2196d31855217c58e7b15899d915d36c768c4a + url: "https://pub.dev" + source: hosted + version: "0.10.9" + workmanager_apple: + dependency: transitive + description: + name: workmanager_apple + sha256: "9e0ca0b857df986b37ca964ffa3b21990e1bc0f7935356a9706b47e5b71a7524" + url: "https://pub.dev" + source: hosted + version: "0.9.11" + workmanager_linux: + dependency: transitive + description: + name: workmanager_linux + sha256: "7d8520e1103b910a43d8bafe37ec2415f4c7c148afcb1eee0ec73ae67b4cdd22" + url: "https://pub.dev" + source: hosted + version: "0.1.1+1" + workmanager_platform_interface: + dependency: transitive + description: + name: workmanager_platform_interface + sha256: a1022dca1284d64ba8bbe046edc9089cc5158b3d0e2cf9f68c7cde2fbf89746c url: "https://pub.dev" source: hosted - version: "6.3.0" + version: "0.10.5" + workmanager_web: + dependency: transitive + description: + name: workmanager_web + sha256: "2ac481ade599bdd957ee2891be09a0c34be87923a2c3ca4dfe36add6fc735ffd" + url: "https://pub.dev" + source: hosted + version: "0.2.0" xdg_directories: dependency: transitive description: @@ -1330,10 +1432,18 @@ packages: dependency: transitive description: name: yaml - sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce + sha256: f67cdd8e07d3c6329146aaef1ba043542b3134c12489f553ca9a7435d1068aea + url: "https://pub.dev" + source: hosted + version: "3.1.4" + yaml_edit: + dependency: transitive + description: + name: yaml_edit + sha256: "07c9e63ba42519745182b88ca12264a7ba2484d8239958778dfe4d44fe760488" url: "https://pub.dev" source: hosted - version: "3.1.3" + version: "2.2.4" yaru: dependency: "direct main" description: @@ -1367,4 +1477,4 @@ packages: version: "0.1.3" sdks: dart: ">=3.12.0 <4.0.0" - flutter: ">=3.44.0" + flutter: ">=3.47.2" diff --git a/pubspec.yaml b/pubspec.yaml index b20926a1..64d660e0 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: busymax description: BusyMax calendar and task manager. publish_to: 'none' -version: 0.2.2 +version: 0.2.2+2 environment: sdk: ^3.12.0 @@ -45,10 +45,15 @@ dependencies: xdg_status_notifier_item: ^0.0.1 xml: 6.6.1 yaru: ^10.2.0 + workmanager: ^0.10.10 + busymax_android_platform: + path: packages/busymax_android_platform dev_dependencies: flutter_test: sdk: flutter + integration_test: + sdk: flutter image: 4.8.0 build_runner: ^2.15.0 drift_dev: ^2.33.0 diff --git a/test/architecture/platform_boundary_test.dart b/test/architecture/platform_boundary_test.dart index 77542dcd..6cd45322 100644 --- a/test/architecture/platform_boundary_test.dart +++ b/test/architecture/platform_boundary_test.dart @@ -11,4 +11,29 @@ void main() { ); expect(failures, isEmpty, reason: failures.join('\n')); }, timeout: const Timeout(Duration(minutes: 2))); + + test('Android traversal follows package imports and exports', () { + final root = Directory.systemTemp.createTempSync( + 'busymax-platform-boundary-', + ); + addTearDown(() => root.deleteSync(recursive: true)); + Directory('${root.path}/lib/src').createSync(recursive: true); + File( + '${root.path}/lib/main_android.dart', + ).writeAsStringSync("import 'package:busymax/src/bridge.dart';\n"); + File('${root.path}/lib/src/android_background.dart').writeAsStringSync(''); + File( + '${root.path}/lib/src/bridge.dart', + ).writeAsStringSync("export 'package:yaru/yaru.dart';\n"); + + final failures = boundary_check.findPlatformBoundaryViolations(root); + + expect( + failures, + contains( + 'Android foreground graph: lib/src/bridge.dart reaches ' + 'desktop-only package:yaru/yaru.dart', + ), + ); + }); } diff --git a/test/db/app_database_test.dart b/test/db/app_database_test.dart index dbe2e96a..bc01b714 100644 --- a/test/db/app_database_test.dart +++ b/test/db/app_database_test.dart @@ -172,7 +172,7 @@ void main() { .select(database.locationResolutions) .getSingle(); - expect(latestSchemaVersion, 16); + expect(latestSchemaVersion, 18); expect(version.read('user_version'), latestSchemaVersion); expect(event.locationLatitude, 49.2827); expect(event.locationLongitude, -123.1207); diff --git a/test/platform/android/android_adaptive_app_test.dart b/test/platform/android/android_adaptive_app_test.dart new file mode 100644 index 00000000..d513a773 --- /dev/null +++ b/test/platform/android/android_adaptive_app_test.dart @@ -0,0 +1,194 @@ +import 'package:busymax/src/android/android_app.dart'; +import 'package:busymax/src/android/android_notifications.dart'; +import 'package:busymax/src/app/app_bootstrap.dart'; +import 'package:busymax/src/config/build_config.dart'; +import 'package:busymax/src/db/app_database.dart'; +import 'package:busymax/src/features/notifications/notification_reconciler.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../support/memory_settings_store.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + const platformChannel = MethodChannel('io.busystack.busymax/android'); + const eventChannel = MethodChannel('io.busystack.busymax/events'); + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + + setUp(() { + messenger.setMockMethodCallHandler(platformChannel, (call) async { + return switch (call.method) { + 'takeInitialActivation' => null, + 'googleAuthorizationAvailable' => false, + 'microsoftAuthorizationAvailable' => false, + _ => null, + }; + }); + messenger.setMockMethodCallHandler(eventChannel, (_) async => null); + }); + + tearDown(() { + messenger.setMockMethodCallHandler(platformChannel, null); + messenger.setMockMethodCallHandler(eventChannel, null); + }); + + testWidgets( + 'compact Android shell uses bottom navigation and accessible taps', + (tester) async { + tester.view.physicalSize = const Size(390, 844); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + final harness = await _pumpApp(tester, AppSettings.defaults()); + addTearDown(harness.dispose); + + expect(find.byType(NavigationBar), findsOneWidget); + expect(find.byType(NavigationRail), findsNothing); + expect(find.text('Schedule'), findsWidgets); + expect(tester.takeException(), isNull); + await expectLater(tester, meetsGuideline(androidTapTargetGuideline)); + await expectLater(tester, meetsGuideline(labeledTapTargetGuideline)); + }, + ); + + testWidgets('wide Android shell uses a rail in dark RTL mode', ( + tester, + ) async { + tester.view.physicalSize = const Size(1000, 800); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + final settings = AppSettings.defaults().copyWith( + localeTag: 'ar', + themeModePreference: BusyMaxThemeModePreference.dark, + ); + final harness = await _pumpApp(tester, settings); + addTearDown(harness.dispose); + + expect(find.byType(NavigationRail), findsOneWidget); + expect(find.byType(NavigationBar), findsNothing); + expect( + Directionality.of(tester.element(find.byType(AndroidHomeShell))), + TextDirection.rtl, + ); + expect( + Theme.of(tester.element(find.byType(AndroidHomeShell))).brightness, + Brightness.dark, + ); + expect(tester.takeException(), isNull); + }); + + testWidgets('compact shell tolerates 200 percent text scaling', ( + tester, + ) async { + tester.view.physicalSize = const Size(390, 844); + tester.view.devicePixelRatio = 1; + tester.platformDispatcher.textScaleFactorTestValue = 2; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + addTearDown(tester.platformDispatcher.clearTextScaleFactorTestValue); + + final harness = await _pumpApp(tester, AppSettings.defaults()); + addTearDown(harness.dispose); + + expect(find.byType(NavigationBar), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + testWidgets('required Android viewport matrix renders every destination', ( + tester, + ) async { + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + addTearDown(tester.platformDispatcher.clearTextScaleFactorTestValue); + tester.view.devicePixelRatio = 1; + const viewports = <(Size, double)>[ + (Size(360, 800), 1), + (Size(412, 915), 1.3), + (Size(800, 1280), 2), + (Size(915, 412), 1.3), + (Size(320, 500), 2), + ]; + + for (final (size, textScale) in viewports) { + tester.view.physicalSize = size; + tester.platformDispatcher.textScaleFactorTestValue = textScale; + tester.binding.handleTextScaleFactorChanged(); + final harness = await _pumpApp(tester, AppSettings.defaults()); + expect( + tester.takeException(), + isNull, + reason: + 'destination 0 at ${size.width}x${size.height} ' + 'and text scale $textScale', + ); + for (final destination in [1, 2]) { + harness.container + .read(androidSelectedDestinationProvider.notifier) + .state = + destination; + await tester.pumpAndSettle(); + expect( + tester.takeException(), + isNull, + reason: + 'destination $destination at ${size.width}x${size.height} ' + 'and text scale $textScale', + ); + } + await harness.dispose(); + } + }); +} + +Future<_AndroidAppHarness> _pumpApp( + WidgetTester tester, + AppSettings settings, +) async { + final database = AppDatabase.memoryForTests(); + final notifications = AndroidNotificationService( + database: database, + settings: () => settings, + ); + final container = ProviderContainer( + overrides: [ + buildConfigProvider.overrideWithValue(BuildConfig.forAndroid()), + databaseProvider.overrideWithValue(database), + initialAppSettingsProvider.overrideWithValue(settings), + localSettingsStoreProvider.overrideWithValue(MemorySettingsStore()), + allAccountsSyncRunnerProvider.overrideWithValue(() async {}), + notificationReconcilerProvider.overrideWithValue( + CallbackNotificationReconciler(() async {}), + ), + androidNotificationServiceProvider.overrideWithValue(notifications), + ], + ); + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: const AndroidBusyMaxApp(), + ), + ); + await tester.pumpAndSettle(); + return _AndroidAppHarness(tester, container, database); +} + +final class _AndroidAppHarness { + const _AndroidAppHarness(this.tester, this.container, this.database); + + final WidgetTester tester; + final ProviderContainer container; + final AppDatabase database; + + Future dispose() async { + await tester.pumpWidget(const SizedBox.shrink()); + container.dispose(); + await database.close(); + } +} diff --git a/test/platform/android/android_notification_policy_test.dart b/test/platform/android/android_notification_policy_test.dart new file mode 100644 index 00000000..befad0cb --- /dev/null +++ b/test/platform/android/android_notification_policy_test.dart @@ -0,0 +1,57 @@ +import 'dart:convert'; + +import 'package:busymax/src/android/android_notifications.dart'; +import 'package:busymax/src/app/app_settings.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:timezone/data/latest.dart' as tz_data; +import 'package:timezone/timezone.dart' as tz; + +void main() { + setUpAll(() { + tz_data.initializeTimeZones(); + tz.setLocalLocation(tz.UTC); + }); + + test('notification IDs are stable and avoid IDs already in use', () { + final first = allocateAndroidNotificationId('schedule-a', {}); + + expect(allocateAndroidNotificationId('schedule-a', {}), first); + expect( + allocateAndroidNotificationId('schedule-a', {first}), + isNot(first), + ); + expect(first, greaterThan(0)); + }); + + test('overnight quiet hours move a reminder to the next quiet-hours end', () { + final settings = AppSettings.defaults().copyWith( + quietHoursEnabled: true, + quietHoursStart: '22:00', + quietHoursEnd: '07:00', + ); + final requested = DateTime.utc(2026, 1, 1, 23).millisecondsSinceEpoch; + + final scheduled = applyAndroidQuietHours(requested, settings); + + expect( + DateTime.fromMillisecondsSinceEpoch(scheduled, isUtc: true), + DateTime.utc(2026, 1, 2, 7), + ); + }); + + test('activation payload parsing rejects incomplete data', () { + expect(parseAndroidReminderActivation('{"schedule":"only"}'), isNull); + final activation = parseAndroidReminderActivation( + jsonEncode({ + 'v': 1, + 'schedule': 'schedule-1', + 'generation': 'generation-4', + 'type': 'task', + 'account': 'account-2', + 'source': 'task-7', + }), + ); + expect(activation?.itemId, 'task-7'); + expect(activation?.generation, 'generation-4'); + }); +} diff --git a/test/platform/android/android_platform_channel_test.dart b/test/platform/android/android_platform_channel_test.dart new file mode 100644 index 00000000..f5b26d5d --- /dev/null +++ b/test/platform/android/android_platform_channel_test.dart @@ -0,0 +1,81 @@ +import 'package:busymax_android_platform/busymax_android_platform.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + const channel = MethodChannel('busymax.android.test'); + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + final calls = []; + + setUp(() { + calls.clear(); + messenger.setMockMethodCallHandler(channel, (call) async { + calls.add(call); + return switch (call.method) { + 'googleAuthorizationAvailable' => false, + 'microsoftAuthorizationAvailable' => true, + 'authorizeMicrosoftSilent' => { + 'accessToken': 'short-lived-access-token', + 'scopes': ['Tasks.ReadWrite'], + 'nativeAccountId': 'native-account-2', + 'username': 'second@example.test', + 'authority': 'organizations', + 'expiresAtEpochMillis': 1893456000000, + }, + 'readDocumentUri' => { + 'uri': 'content://documents/event.ics', + 'name': 'event.ics', + 'mimeType': 'text/calendar', + 'bytes': Uint8List.fromList([66, 69, 71, 73, 78]), + }, + _ => null, + }; + }); + }); + + tearDown(() => messenger.setMockMethodCallHandler(channel, null)); + + test('reports provider availability independently', () async { + final platform = BusyMaxAndroidPlatform(methodChannel: channel); + + expect(await platform.googleAuthorizationAvailable(), isFalse); + expect(await platform.microsoftAuthorizationAvailable(), isTrue); + }); + + test( + 'silent Microsoft authorization remains bound to the requested account', + () async { + final platform = BusyMaxAndroidPlatform(methodChannel: channel); + + final token = await platform.authorizeMicrosoftSilently( + accountId: 'domain-account-2', + scopes: const ['Tasks.ReadWrite'], + ); + + expect(token.nativeAccountId, 'native-account-2'); + expect(token.username, 'second@example.test'); + expect( + calls.single.arguments, + containsPair('accountId', 'domain-account-2'), + ); + }, + ); + + test( + 'incoming documents remain bounded content URIs, not file paths', + () async { + final platform = BusyMaxAndroidPlatform(methodChannel: channel); + + final document = await platform.readDocumentUri( + Uri.parse('content://documents/event.ics'), + maximumBytes: 1024, + ); + + expect(document.uri.scheme, 'content'); + expect(document.name, 'event.ics'); + expect(calls.single.arguments, containsPair('maximumBytes', 1024)); + }, + ); +} diff --git a/test/platform/android/android_settings_persistence_test.dart b/test/platform/android/android_settings_persistence_test.dart new file mode 100644 index 00000000..62d2dc91 --- /dev/null +++ b/test/platform/android/android_settings_persistence_test.dart @@ -0,0 +1,26 @@ +import 'package:busymax/src/app/app_settings.dart'; +import 'package:busymax/src/schedule/schedule_view_mode.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test( + 'Android view starts in agenda without changing the desktop default', + () { + final defaults = AppSettings.defaults(); + + expect(defaults.androidScheduleViewMode, isNull); + expect(defaults.scheduleViewMode, ScheduleViewMode.week); + }, + ); + + test('Android view mode round-trips independently', () { + final restored = AppSettings.fromJson( + AppSettings.defaults() + .copyWith(androidScheduleViewMode: ScheduleViewMode.month) + .toJson(), + ); + + expect(restored.androidScheduleViewMode, ScheduleViewMode.month); + expect(restored.scheduleViewMode, ScheduleViewMode.week); + }); +} diff --git a/tool/android/build_release.sh b/tool/android/build_release.sh new file mode 100755 index 00000000..f4dfd2e7 --- /dev/null +++ b/tool/android/build_release.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +kind=all +test_signing=false +allow_unconfigured=false +while (($#)); do + case "$1" in + --apk) kind=apk; shift ;; + --aab) kind=aab; shift ;; + --all) kind=all; shift ;; + --test-signing) test_signing=true; shift ;; + --allow-unconfigured-providers) allow_unconfigured=true; shift ;; + *) echo 'Usage: tool/android/build_release.sh [--apk|--aab|--all] [--test-signing] [--allow-unconfigured-providers]' >&2; exit 64 ;; + esac +done + +prerequisite_args=() +if ! $allow_unconfigured; then prerequisite_args+=(--require-provider-config); fi +if ! $test_signing; then prerequisite_args+=(--require-signing); fi +"$repo_root/tool/android/check_prerequisites.sh" "${prerequisite_args[@]}" + +flutter_bin="${BUSYMAX_FLUTTER_EXECUTABLE:-$(command -v flutter)}" +config="$repo_root/android/busymax.android.properties" +defines=() +property_value() { + [[ -f "$config" ]] || return 0 + sed -n "s/^$1=//p" "$config" | head -n 1 +} +for mapping in \ + 'busymax.privacyPolicyUrl:BUSYMAX_PRIVACY_POLICY_URL' \ + 'busymax.supportUrl:BUSYMAX_SUPPORT_URL' \ + 'busymax.homepageUrl:BUSYMAX_HOMEPAGE_URL'; do + property="${mapping%%:*}" + define="${mapping##*:}" + value="$(property_value "$property")" + if [[ -n "$value" ]]; then defines+=("--dart-define=$define=$value"); fi +done + +cd "$repo_root" +export ORG_GRADLE_PROJECT_busymaxTestSigning="$test_signing" +if [[ "$kind" == apk || "$kind" == all ]]; then + "$flutter_bin" build apk --release -t lib/main_android.dart "${defines[@]}" +fi +if [[ "$kind" == aab || "$kind" == all ]]; then + "$flutter_bin" build appbundle --release -t lib/main_android.dart "${defines[@]}" +fi +if $test_signing; then + echo 'Artifacts are explicitly TEST-SIGNED with the local debug identity; they are not production distribution artifacts.' +else + echo 'Artifacts were signed using the untracked android/key.properties identity.' +fi diff --git a/tool/android/check_prerequisites.sh b/tool/android/check_prerequisites.sh new file mode 100755 index 00000000..d6996000 --- /dev/null +++ b/tool/android/check_prerequisites.sh @@ -0,0 +1,86 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +require_provider=false +require_signing=false +for argument in "$@"; do + case "$argument" in + --require-provider-config) require_provider=true ;; + --require-signing) require_signing=true ;; + *) echo "Unknown option: $argument" >&2; exit 64 ;; + esac +done + +flutter_bin="${BUSYMAX_FLUTTER_EXECUTABLE:-$(command -v flutter || true)}" +if [[ -z "$flutter_bin" || ! -x "$flutter_bin" ]]; then + echo 'Flutter is missing. Install Flutter 3.47.2 and put it on PATH.' >&2 + exit 1 +fi +flutter_bin="$(readlink -f "$flutter_bin")" +dart_bin="$(dirname "$flutter_bin")/cache/dart-sdk/bin/dart" +if [[ ! -x "$dart_bin" ]]; then + echo 'The bundled Dart executable could not be found beside Flutter.' >&2 + exit 1 +fi +"$dart_bin" "$repo_root/tool/verify_flutter_sdk.dart" \ + --flutter "$flutter_bin" \ + --dart "$dart_bin" \ + --expected-flutter 3.47.2 \ + --expected-dart 3.13.2 + +java_version="$(java -version 2>&1 | sed -n '1s/.*version "\([^"]*\)".*/\1/p')" +if [[ "$java_version" != 17.* ]]; then + echo "JDK 17 is required; the active Java reports '${java_version:-unknown}'." >&2 + exit 1 +fi + +sdk_root="${ANDROID_SDK_ROOT:-${ANDROID_HOME:-}}" +if [[ -z "$sdk_root" && -f "$repo_root/android/local.properties" ]]; then + sdk_root="$(sed -n 's/^sdk\.dir=//p' "$repo_root/android/local.properties" | head -n 1)" +fi +if [[ -z "$sdk_root" || ! -d "$sdk_root" ]]; then + echo 'Set ANDROID_SDK_ROOT or android/local.properties to an Android SDK.' >&2 + exit 1 +fi +if [[ ! -d "$sdk_root/platforms/android-37" && ! -d "$sdk_root/platforms/android-37.0" ]]; then + echo 'Android SDK platform API 37 is required.' >&2 + exit 1 +fi +build_tools=("$sdk_root"/build-tools/37*) +if [[ ! -d "${build_tools[0]}" ]]; then + echo 'Android SDK Build Tools 37.x are required.' >&2 + exit 1 +fi + +config="$repo_root/android/busymax.android.properties" +if $require_provider; then + if [[ ! -f "$config" ]]; then + echo 'Copy android/busymax.android.properties.example to android/busymax.android.properties and configure the public provider registrations.' >&2 + exit 1 + fi + for key in microsoft.clientId microsoft.signatureHash; do + if ! grep -Eq "^${key//./\\.}=.+" "$config"; then + echo "Android public configuration is missing $key." >&2 + exit 1 + fi + done +fi + +if $require_signing; then + signing="$repo_root/android/key.properties" + if [[ ! -f "$signing" ]]; then + echo 'Production signing requires an untracked android/key.properties file.' >&2 + exit 1 + fi + for key in storeFile storePassword keyAlias keyPassword; do + if ! grep -Eq "^${key}=.+" "$signing"; then + echo "Android signing configuration is missing $key." >&2 + exit 1 + fi + done +fi + +echo 'PASSED: Flutter 3.47.2, bundled Dart 3.13.2, JDK 17, Android API 37, and Build Tools 37.x are available.' +echo "Flutter: $flutter_bin" +echo "Android SDK: $sdk_root" diff --git a/tool/android/inspect_artifacts.sh b/tool/android/inspect_artifacts.sh new file mode 100755 index 00000000..89d2a836 --- /dev/null +++ b/tool/android/inspect_artifacts.sh @@ -0,0 +1,70 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +apk="${1:-$repo_root/build/app/outputs/flutter-apk/app-release.apk}" +aab="${2:-$repo_root/build/app/outputs/bundle/release/app-release.aab}" +report="$repo_root/build/android/android-artifact-inspection.txt" +mkdir -p "$(dirname "$report")" +: > "$report" +log() { printf '%s\n' "$*" | tee -a "$report"; } +for artifact in "$apk" "$aab"; do + if [[ ! -f "$artifact" ]]; then + log "FAILED: missing artifact $artifact" + exit 1 + fi + log "ARTIFACT: $artifact" + log "SHA-256: $(sha256sum "$artifact" | cut -d' ' -f1)" + log 'Native libraries:' + unzip -Z1 "$artifact" '*.so' 2>/dev/null | tee -a "$report" || true +done + +readelf_bin="$(command -v llvm-readelf || command -v readelf || true)" +if [[ -z "$readelf_bin" ]]; then + log 'BLOCKED: neither llvm-readelf nor readelf is available for ELF segment inspection.' + exit 1 +fi +temporary="$(mktemp -d)" +trap 'rm -rf "$temporary"' EXIT +for artifact in "$apk" "$aab"; do + while IFS= read -r entry; do + extracted="$temporary/$(basename "$entry")" + unzip -p "$artifact" "$entry" > "$extracted" + while IFS= read -r alignment; do + if (( alignment < 0x4000 )); then + log "FAILED: $entry has ELF LOAD alignment $alignment below 0x4000." + exit 1 + fi + done < <("$readelf_bin" -lW "$extracted" | awk '$1 == "LOAD" {print $NF}') + done < <(unzip -Z1 "$artifact" '*.so' 2>/dev/null || true) + log "PASSED: native ELF LOAD segments in $(basename "$artifact") are 16 KB compatible." +done + +sdk_root="${ANDROID_SDK_ROOT:-${ANDROID_HOME:-}}" +if [[ -z "$sdk_root" && -f "$repo_root/android/local.properties" ]]; then + sdk_root="$(sed -n 's/^sdk\.dir=//p' "$repo_root/android/local.properties" | head -n 1)" +fi +build_tools="$(find "$sdk_root/build-tools" -mindepth 1 -maxdepth 1 -type d -name '37*' | sort -V | tail -n 1)" +"$build_tools/zipalign" -c -P 16 4 "$apk" | tee -a "$report" +"$build_tools/apksigner" verify --verbose --print-certs "$apk" | tee -a "$report" +if command -v keytool >/dev/null; then + keytool -printcert -jarfile "$aab" | tee -a "$report" +fi +apkanalyzer_bin="$(command -v apkanalyzer || true)" +if [[ -z "$apkanalyzer_bin" && -x "$sdk_root/cmdline-tools/latest/bin/apkanalyzer" ]]; then + apkanalyzer_bin="$sdk_root/cmdline-tools/latest/bin/apkanalyzer" +fi +if [[ -n "$apkanalyzer_bin" ]]; then + "$apkanalyzer_bin" manifest application-id "$apk" | tee -a "$report" + "$apkanalyzer_bin" manifest version-name "$apk" | tee -a "$report" + "$apkanalyzer_bin" manifest version-code "$apk" | tee -a "$report" + "$apkanalyzer_bin" manifest min-sdk "$apk" | tee -a "$report" + "$apkanalyzer_bin" manifest target-sdk "$apk" | tee -a "$report" + "$apkanalyzer_bin" manifest permissions "$apk" | tee -a "$report" +fi +if [[ -n "${BUNDLETOOL_JAR:-}" && -f "$BUNDLETOOL_JAR" ]]; then + java -jar "$BUNDLETOOL_JAR" dump config --bundle="$aab" | tee -a "$report" +else + log 'BLOCKED: AAB bundletool alignment metadata was not inspected; set BUNDLETOOL_JAR to a local bundletool jar.' +fi +log "Inspection report: $report" diff --git a/tool/android/run.sh b/tool/android/run.sh new file mode 100755 index 00000000..938fb7ca --- /dev/null +++ b/tool/android/run.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +device='' +extra=() +while (($#)); do + case "$1" in + --device) device="${2:-}"; shift 2 ;; + --) shift; extra+=("$@"); break ;; + *) echo 'Usage: tool/android/run.sh --device DEVICE_ID [-- flutter-run-options]' >&2; exit 64 ;; + esac +done +if [[ -z "$device" ]]; then + echo 'A device is required: tool/android/run.sh --device DEVICE_ID' >&2 + exit 64 +fi +"$repo_root/tool/android/check_prerequisites.sh" +flutter_bin="${BUSYMAX_FLUTTER_EXECUTABLE:-$(command -v flutter)}" +if ! "$flutter_bin" devices | grep -Fq -- "$device"; then + echo "Flutter does not report a connected device matching '$device'." >&2 + exit 1 +fi +cd "$repo_root" +exec "$flutter_bin" run -d "$device" -t lib/main_android.dart "${extra[@]}" diff --git a/tool/android/verify.sh b/tool/android/verify.sh new file mode 100755 index 00000000..424aa80c --- /dev/null +++ b/tool/android/verify.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +build=false +if [[ "${1:-}" == '--build-test-signed' ]]; then build=true; shift; fi +if (($#)); then + echo 'Usage: tool/android/verify.sh [--build-test-signed]' >&2 + exit 64 +fi +"$repo_root/tool/android/check_prerequisites.sh" +flutter_bin="${BUSYMAX_FLUTTER_EXECUTABLE:-$(command -v flutter)}" +flutter_bin="$(readlink -f "$flutter_bin")" +dart_bin="$(dirname "$flutter_bin")/cache/dart-sdk/bin/dart" +cd "$repo_root" +"$flutter_bin" pub get --enforce-lockfile +"$flutter_bin" gen-l10n +"$dart_bin" run build_runner build --force-jit +git diff --exit-code -- lib/l10n/generated lib/src/db/app_database.g.dart +"$dart_bin" format --output=none --set-exit-if-changed . +"$flutter_bin" analyze +"$dart_bin" run tool/check_platform_boundaries.dart +"$flutter_bin" test +"$repo_root/android/gradlew" --project-dir "$repo_root/android" \ + :busymax_android_platform:testDebugUnitTest :app:lintDebug +if $build; then + "$repo_root/tool/android/build_release.sh" --all --test-signing \ + --allow-unconfigured-providers + "$repo_root/tool/android/inspect_artifacts.sh" +fi diff --git a/tool/check_platform_boundaries.dart b/tool/check_platform_boundaries.dart index 763ed7d3..f77b3310 100644 --- a/tool/check_platform_boundaries.dart +++ b/tool/check_platform_boundaries.dart @@ -14,6 +14,14 @@ const _fluentPackages = [ 'package:fluentui_system_icons/', ]; +const _desktopOnlyPackages = [ + ..._linuxOnlyPackages, + ..._fluentPackages, + 'package:flutter_timezone/', + 'package:tray_manager/', + 'package:yaru_window/', +]; + final _importPattern = RegExp( r'''^\s*(?:import|export)\s+['"]([^'"]+)['"]''', multiLine: true, @@ -79,11 +87,57 @@ List findPlatformBoundaryViolations(Directory root) { } _checkWindowsReachability(root, failures); + _checkAndroidReachability(root, failures); _checkNativePluginIsolation(root, failures); failures.sort(); return failures; } +void _checkAndroidReachability(Directory root, List failures) { + for (final entry in const [ + 'lib/main_android.dart', + 'lib/src/android/android_background.dart', + ]) { + _checkReachability( + root, + entry, + entry == 'lib/main_android.dart' + ? 'Android foreground graph' + : 'Android headless graph', + failures, + ); + } +} + +void _checkReachability( + Directory root, + String entry, + String label, + List failures, +) { + final entrypoint = File('${root.path}/$entry'); + if (!entrypoint.existsSync()) { + failures.add('$label entrypoint is missing: $entry'); + return; + } + final queue = [entrypoint]; + final visited = {}; + while (queue.isNotEmpty) { + final file = queue.removeLast(); + if (!visited.add(file.path) || !file.existsSync()) continue; + final relative = _relative(root.path, file.path); + for (final match in _importPattern.allMatches(file.readAsStringSync())) { + final import = match.group(1)!; + if (_desktopOnlyPackages.any(import.startsWith) || + _isDesktopSourceImport(import)) { + failures.add('$label: $relative reaches desktop-only $import'); + } + final resolved = _resolveLocalImport(root, file, import); + if (resolved != null) queue.add(resolved); + } + } +} + void _checkNativePluginIsolation(Directory root, List failures) { final linuxPlugins = File( '${root.path}/linux/flutter/generated_plugins.cmake', @@ -140,12 +194,25 @@ void _checkWindowsReachability(Directory root, List failures) { File? _resolveLocalImport(Directory root, File source, String import) { if (import.startsWith('package:busymax/')) { - return File('${root.path}/${import.substring('package:busymax/'.length)}'); + return File( + '${root.path}/lib/${import.substring('package:busymax/'.length)}', + ); } if (import.startsWith('dart:') || import.startsWith('package:')) return null; return File(Uri.file(source.path).resolve(import).toFilePath()); } +bool _isDesktopSourceImport(String import) { + final normalized = import.replaceAll('\\', '/').toLowerCase(); + return normalized.endsWith('/main_linux.dart') || + normalized.endsWith('/main_windows.dart') || + normalized.contains('/ui/linux/') || + normalized.contains('/ui/windows/') || + normalized.contains('/platform/linux/') || + normalized.contains('/platform/windows/') || + normalized.contains('/app/windows/'); +} + bool _isLinuxSourceImport(String import) { final normalized = import.replaceAll('\\', '/').toLowerCase(); return normalized.contains('/linux/') || diff --git a/tool/generate_android_assets.dart b/tool/generate_android_assets.dart new file mode 100644 index 00000000..8747e6f5 --- /dev/null +++ b/tool/generate_android_assets.dart @@ -0,0 +1,53 @@ +import 'dart:io'; + +import 'package:image/image.dart' as image; + +void main() { + final source = image.decodePng( + File('assets/branding/busymax-logo.png').readAsBytesSync(), + ); + if (source == null) throw StateError('BusyMax logo is not a valid PNG.'); + const sizes = { + 'mdpi': 48, + 'hdpi': 72, + 'xhdpi': 96, + 'xxhdpi': 144, + 'xxxhdpi': 192, + }; + for (final entry in sizes.entries) { + final directory = Directory('android/app/src/main/res/mipmap-${entry.key}') + ..createSync(recursive: true); + final icon = image.copyResize( + source, + width: entry.value, + height: entry.value, + interpolation: image.Interpolation.cubic, + ); + final foreground = image.Image( + width: entry.value, + height: entry.value, + numChannels: 4, + ); + final inset = image.copyResize( + source, + width: (entry.value * .72).round(), + height: (entry.value * .72).round(), + interpolation: image.Interpolation.cubic, + ); + image.compositeImage( + foreground, + inset, + dstX: (entry.value - inset.width) ~/ 2, + dstY: (entry.value - inset.height) ~/ 2, + ); + File( + '${directory.path}/ic_launcher.png', + ).writeAsBytesSync(image.encodePng(icon)); + File( + '${directory.path}/ic_launcher_round.png', + ).writeAsBytesSync(image.encodePng(icon)); + File( + '${directory.path}/ic_launcher_foreground.png', + ).writeAsBytesSync(image.encodePng(foreground)); + } +} From a8d060444c65ecf7f70076c0247a1ea5c6645317 Mon Sep 17 00:00:00 2001 From: albert Date: Mon, 14 Sep 2026 16:50:36 -0700 Subject: [PATCH 03/25] Fixed Android implementation: - Guest metadata preservation and accurate attendee-change detection. - Reminder quiet-hours reconciliation, Open actions, privacy/exact-mode replacement, permission prompting, and offline refill. - Native-scope validation and authentication recovery. - Engine-owned synchronization leases released on worker/engine shutdown. - Source visibility filters and immediate calendar synchronization. - Multiday/DST-safe calendar rendering and five distinct calendar modes. - Draft recovery, discard confirmation, read-only capability enforcement, and account-qualified destinations. - Nextcloud sharing, publishing, trash/restore, advanced task operations, and independent Microsoft reminder editing. - Background document I/O, contextual LAN permission handling, and Google binding cleanup. --- docs/android_implementation_status.md | 49 +- docs/android_provider_parity.md | 15 +- docs/android_verification.md | 36 +- lib/src/android/android_app.dart | 1 + lib/src/android/android_authorization.dart | 121 +- lib/src/android/android_background.dart | 72 +- lib/src/android/android_notifications.dart | 129 +- .../presentation/android_schedule_screen.dart | 1630 +++++++++---- .../presentation/android_settings_screen.dart | 494 +++- .../presentation/android_tasks_screen.dart | 2009 +++++++++++++++-- lib/src/features/sync/sync_auth_error.dart | 9 +- .../BusymaxAndroidPlatformPlugin.kt | 101 +- .../BusymaxAndroidPlatformPluginTest.kt | 13 + .../lib/busymax_android_platform.dart | 7 + .../android/android_adaptive_app_test.dart | 353 ++- .../android_authorization_broker_test.dart | 107 + .../android_event_attendee_edit_test.dart | 51 + .../android_local_network_host_test.dart | 32 + .../android_notification_policy_test.dart | 65 + .../android_platform_channel_test.dart | 8 + 20 files changed, 4520 insertions(+), 782 deletions(-) create mode 100644 test/platform/android/android_authorization_broker_test.dart create mode 100644 test/platform/android/android_event_attendee_edit_test.dart create mode 100644 test/platform/android/android_local_network_host_test.dart diff --git a/docs/android_implementation_status.md b/docs/android_implementation_status.md index 60bd9403..c6b3de8d 100644 --- a/docs/android_implementation_status.md +++ b/docs/android_implementation_status.md @@ -3,9 +3,14 @@ The Android entrypoint composes a Material 3 Schedule, Tasks, and Settings application independently of the Yaru Linux and Fluent Windows entrypoints. Compact windows use bottom navigation and wider windows use a navigation rail. -Schedule supports agenda, day, week, month, and year modes, date navigation, -search, source filters, details, maps handoff, and contextual creation. Mobile -event/task editors use the shared drafts and concrete provider capabilities. +Schedule supports a virtual agenda, day/week time grids with all-day and +overlap placement, a civil-date month grid with selected-day agenda, and a +year of month miniatures. Date navigation, search, calendar/task-list filters, +details, maps handoff, and contextual creation are Android-owned Material +flows. Empty periods retain the selected calendar view, and intersecting +overnight/multiday events appear on every applicable day. Mobile event/task +editors use concrete provider capabilities, account-qualified destinations, +discard confirmation, and encrypted draft recovery. The app-owned `busymax_android_platform` plugin owns Google Identity Services, MSAL, account binding, secure cross-engine account gates, Android system time @@ -13,26 +18,38 @@ settings, document/content URI activation, document-tree export, external URI handoff, local-network permission, and cold/warm activation delivery. Desktop OAuth loopback flows are not used by Android. -WorkManager registers unique 15-minute periodic work with a five-minute flex, -network/battery/storage constraints, bounded exponential backoff, and a -replaceable immediate request. A headless provider container uses no widgets. -Foreground and headless mutations serialize by account; notification -reconciliation has its own process-wide gate and signals the foreground engine -after background changes. +WorkManager registers separate unique 15-minute provider-sync and local +notification-refill jobs. Provider sync requires a network; reminder refill +does not, so cached reminders are maintained while offline or after a provider +failure. Both use battery/storage constraints and bounded exponential backoff, +and provider mutations can also request a replaceable immediate sync. A +headless provider container uses no widgets. Foreground and headless mutations +serialize by account; notification reconciliation has its own process-wide +gate. Native leases are engine-owned and released both by the worker stop +callback and native engine teardown, preventing an abandoned worker from +holding synchronization indefinitely. Android notification mappings are durable database rows. Reconciliation uses a 90-day/256-alarm horizon, stable collision-safe IDs, generation checks, exact alarms when allowed and inexact fallback otherwise, quiet hours, private text, Open/Snooze/Dismiss, due-today deduplication, and sync/conflict status channels. -Snooze/Dismiss persist and reconcile from the background callback without -requiring a live UI engine. Future scheduling is never recorded as proof that a -notification was displayed. +Quiet-hour-adjusted delivery time is used for reconciliation, pending overdue +inexact alarms are retained, and privacy/precision changes replace stale +registrations. The explicit Open action launches the UI. Ordinary reminder +creation requests notification permission. Snooze/Dismiss persist and +reconcile from the background callback without requiring a live UI engine. +Future scheduling is never recorded as proof that a notification was displayed. ICS import previews content and destination before mutation; incoming shared -URIs are bounded `content:` reads. Single DAV items and native collections use -Storage Access Framework output. Settings persistence uses flush plus atomic -rename. Backup/transfer rules exclude the secure store, preferences, and local -database. The database itself is app-private but not described as encrypted. +URIs are bounded `content:` reads. Content-provider reads and document writes +run off Android's UI thread. Single DAV items and native collections use +Storage Access Framework output. Android routes expose supported Nextcloud +sharing, publishing, trash/restore, task hierarchy, advanced VTODO fields, and +native export. Android 17 local-network permission is requested only for +contextual hostnames/private addresses and denial stops onboarding. Settings +persistence uses flush plus atomic rename. Backup/transfer rules exclude the +secure store, preferences, and local database. The database itself is +app-private but not described as encrypted. External console registration, production signing, Play app-signing identity, physical-device lifecycle testing, live disposable-provider accounts, and store diff --git a/docs/android_provider_parity.md b/docs/android_provider_parity.md index fd3bd847..e330db91 100644 --- a/docs/android_provider_parity.md +++ b/docs/android_provider_parity.md @@ -10,11 +10,14 @@ selected concrete collection. | Google | Native Google Identity Services `AuthorizationClient`; explicit chooser; account-specific silent tokens | Read/write, recurrence, reminders, guests, colors and supported calendar mutation | Read/write Google Tasks fields; no invented reminder/time/recurrence support | Calendar/list creation and supported rename/remove/delete are queued through existing cloud mutations. Google alone is disabled when Play services is absent. | | Microsoft | Native MSAL multiple-account public client; browser interactive flow; exact native-account silent lookup | Read/write Microsoft Graph calendars and supported recurrence/reminder/attendee fields | Read/write Microsoft To Do including supported due time, reminders, importance, categories, progress, recurrence, and hierarchy | Built-in/non-owner restrictions remain enforced. Signer-specific redirects must be registered. | | Apple iCloud | CalDAV with user-supplied app-specific password in secure storage | Calendar-only read/write according to server privileges | Not supported | BusyMax does not imply Apple Reminders support or collection creation where the existing capability matrix denies it. | -| Nextcloud | Login Flow v2 and secure app-password storage | CalDAV read/write, native ICS, collection permissions, sharing/publishing/trash services | VTODO read/write with per-collection DAV capabilities | Calendar/task-list create, rename, delete/remove, reminders, and document-tree native export are available only when server privileges allow them. Local hosts use contextual Android 17 LAN permission. | +| Nextcloud | Login Flow v2 and secure app-password storage | CalDAV read/write, native ICS, collection permissions, and Android sharing/publishing/trash/restore routes | VTODO read/write with per-collection DAV capabilities; due/start/completion, status/progress/priority, recurrence, multiple alarms, hierarchy, duplicate/move and native export are exposed when supported | Calendar/task-list create, rename, delete/remove, sharing grants, publish/unpublish, trash restore/permanent delete, reminders, and document-tree native export are available only when server privileges allow them. Local hosts use contextual Android 17 LAN permission and denial aborts onboarding. | | WebCal | Confirmed subscription URL; embedded credentials remain in credential storage | Read-only subscription refresh and local rename/color metadata | Not supported | No event mutation or provider collection write is presented. | -All five schedule views are available. Android defaults independently to -Agenda, while the existing desktop default remains Week. Creating from the -Schedule screen always asks for a destination when more than one writable -calendar or task list exists, preserving account/list identity even when remote -IDs collide. +All five schedule views are available as distinct implementations: virtual +Agenda, time-axis Day/Week, scrollable Month with selected-day agenda, and Year +month miniatures. Android defaults independently to Agenda, while the existing +desktop default remains Week. Creating from the Schedule screen always asks for +a destination when more than one writable calendar or task list exists and +shows the account with the collection, preserving identity even when names or +remote IDs collide. Microsoft's task reminder is edited independently from its +due/start date and time. diff --git a/docs/android_verification.md b/docs/android_verification.md index 8df9eace..e2525629 100644 --- a/docs/android_verification.md +++ b/docs/android_verification.md @@ -12,8 +12,9 @@ test is never counted as a pass. | Item | Recorded value | |---|---| | Source baseline | `9350af42e299f9da85ea2d046792a76e3a562cba` | +| Reviewed Android foundation | `263a99e5b747fa344de3c8c775ccda2d4bbdfbe0` plus the uncommitted review corrections described here | | Verification host | `skynet3`, Linux x86_64, Ubuntu kernel `7.0.0-31-generic` | -| Verification time | `2026-09-14T13:40:36-07:00` (`America/Vancouver`) | +| Verification time | `2026-09-14T16:44:01-07:00` (`America/Vancouver`) | | Flutter / Dart | Flutter `3.47.2` (`d3b14c8769`), bundled Dart `3.13.2` | | Java | Eclipse Temurin JDK `17.0.18+8` | | Android toolchain | platform `37.0` revision 2, Build Tools `37.0.0`, Gradle `9.4.1`, AGP `9.2.0` | @@ -27,15 +28,18 @@ test is never counted as a pass. |---|---|---|---| | PASSED | Pinned SDK prerequisites | `tool/android/check_prerequisites.sh` with Temurin JDK 17 | Flutter 3.47.2, Dart 3.13.2, API 37 and Build Tools 37.x accepted | | PASSED | Localization and Drift generation | `flutter gen-l10n`; bundled `dart run build_runner build --force-jit` | Generated localization and schema sources are current; the existing Drift duplicate-reference warning remains non-fatal | -| PASSED | Formatting | bundled `dart format --output=none --set-exit-if-changed .` | 710 files unchanged by the check | +| PASSED | Formatting | bundled `dart format --output=none --set-exit-if-changed .` | 713 files unchanged by the check | | PASSED | Static analysis | `flutter analyze` | `No issues found!` | | PASSED | Platform boundary enforcement | bundled `dart run tool/check_platform_boundaries.dart` | Android foreground/headless graphs contain no desktop imports; malicious-fixture regression also passed | -| PASSED | Unit and widget suite | `flutter test --reporter compact` | 2,204 runnable tests passed; 10 live/environment tests skipped and are not reported as passes; [flutter-test.log](../build/android/flutter-test.log) | -| PASSED | Android adaptive widget matrix | `flutter test test/platform/android/android_adaptive_app_test.dart` | Schedule, Tasks and Settings exercised at 360x800, 412x915, 800x1280, landscape 915x412 and split 320x500, text scales 1.0/1.3/2.0, light/dark and RTL; no overflow; compact/wide navigation, labels and 48 dp controls checked | -| PASSED | Android notification policy | `flutter test test/platform/android/android_notification_policy_test.dart` | Stable IDs, collision handling, quiet-hours behavior and generation-bearing action payloads | -| PASSED | Android platform channel contract | `flutter test test/platform/android/android_platform_channel_test.dart` | Provider availability, account-specific silent Microsoft request and content-URI behavior | +| PASSED | Unit and widget suite | `flutter test --reporter compact` | 2,223 runnable tests passed; 10 live/environment tests skipped and are not reported as passes; terminal result: `All other tests passed!` | +| PASSED | Android adaptive widget matrix | `flutter test test/platform/android/android_adaptive_app_test.dart` | Schedule, Tasks and Settings exercised at 360x800, 412x915, 800x1280, landscape 915x412 and split 320x500, text scales 1.0/1.3/2.0, light/dark and RTL. Populated fixtures verify the five calendar renderers, an overnight event on its intersecting day, hidden calendar/task-list exclusion, read-only event/task actions, and discard confirmation for modified event/task editors. No overflow; compact/wide navigation, labels and 48 dp controls checked. | +| PASSED | Android notification policy | `flutter test test/platform/android/android_notification_policy_test.dart` | Stable IDs/collision handling, post-quiet-hours eligibility, pending overdue inexact retention, privacy/exact registration replacement, explicit Open UI action and generation-bearing payload parsing | +| PASSED | Android authorization regression | `flutter test test/platform/android/android_authorization_broker_test.dart` | Missing native scopes are not promoted to grants, native interaction-required becomes the shared reconnect error, and non-revoking local Google removal clears its native binding | +| PASSED | Android attendee mutation regression | `flutter test test/platform/android/android_event_attendee_edit_test.dart` | Unchanged guest text preserves the original structured attendee objects and change flag; additions retain existing guest metadata | +| PASSED | Android contextual LAN regression | `flutter test test/platform/android/android_local_network_host_test.dart` | Local/contextual DNS, private/link-local IPv4, loopback/ULA/link-local IPv6 and public DNS/IP cases classified independently | +| PASSED | Android platform channel contract | `flutter test test/platform/android/android_platform_channel_test.dart` | Provider availability, account-specific silent Microsoft request, content-URI behavior, and explicit engine-owned gate release contract | | PASSED | Android settings persistence | `flutter test test/platform/android/android_settings_persistence_test.dart` | Android view preference is independent and atomically persisted | -| PASSED | Native plugin unit tests | `android/gradlew --project-dir android :busymax_android_platform:testDebugUnitTest` | JUnit report under `build/busymax_android_platform/reports/tests/testDebugUnitTest/`; unknown methods return `notImplemented` | +| PASSED | Native plugin unit tests | `android/gradlew --project-dir android :busymax_android_platform:testDebugUnitTest` | 2 tests, 0 failures/errors/skips in the JUnit report under `build/busymax_android_platform/test-results/testDebugUnitTest/`; unknown methods return `notImplemented`, and releasing an engine owner frees its held account lease | | PASSED | Android lint | `android/gradlew --project-dir android :app:lintDebug` | [lint-results-debug.html](../build/app/reports/lint-results-debug.html) | | PASSED | Release-mode shrinking and packaging | `tool/android/build_release.sh --all --test-signing --allow-unconfigured-providers` | R8/resource shrinking completed; final APK and AAB paths are recorded below | | PASSED | Release artifact inspection | `tool/android/inspect_artifacts.sh` | [android-artifact-inspection.txt](../build/android/android-artifact-inspection.txt) | @@ -57,8 +61,8 @@ inspection only, not owner distribution or Play upload. | Artifact | Size | SHA-256 | |---|---:|---| -| `build/app/outputs/flutter-apk/app-release.apk` | 84,506,244 bytes | `775fb6f3753b1e07b262453147030bad4dea988a8eb806b6738dbd12a91bff81` | -| `build/app/outputs/bundle/release/app-release.aab` | 80,651,652 bytes | `0a862e8a0d3bff9ec8e63a56145c2c4cb0b402d98d38761f00f2e6e2d37fcf49` | +| `build/app/outputs/flutter-apk/app-release.apk` | 85,965,568 bytes | `b7f7cec2bbce986eb9e0313a03662d93b2073a8bb6c88a4476ef48647935f94e` | +| `build/app/outputs/bundle/release/app-release.aab` | 81,956,754 bytes | `7a2838b86d5afe46fcf0163fe3147163bd7059ba78f1046c411715b2ac116105` | Inspection found `arm64-v8a`, `armeabi-v7a`, and `x86_64`, with Flutter, application, Dart JNI, and SQLite native libraries in each ABI. Every packaged @@ -88,25 +92,25 @@ provider account, or owner registration. |---|---|---| | PASSED | Bootstrap and navigation — automated | Empty/populated/error/offline repository states, adaptive navigation, selected dates/filters, and cold/warm activation routing are covered by the full suite and Android widget/channel tests. | | BLOCKED | Bootstrap and navigation — installed app | Fresh/returning install, process restoration, and cold/warm file/notification launches require an Android device and installed APK. | -| PASSED | Google — implementation/contract | Native `AuthorizationClient`, explicit selection, exact BusyMax/native binding, silent token path, removal isolation, Play-services availability, Calendar/Tasks repository reuse, and no Android desktop OAuth imports are implemented and contract-tested. | +| PASSED | Google — implementation/contract | Native `AuthorizationClient`, explicit selection, exact BusyMax/native binding, provider-reported grant persistence, required-scope validation, reconnect error mapping, local binding removal, Play-services availability, Calendar/Tasks repository reuse, and no Android desktop OAuth imports are implemented and contract-tested. | | BLOCKED | Google — live | Two disposable Google accounts, enabled Calendar/Tasks APIs, and Android OAuth registrations for the debug, local-release and Play app-signing certificate fingerprints are required to exercise consent/cancel/deny/expiry/reconnect, real data, removal, and foreground/headless silent acquisition. | -| PASSED | Microsoft — implementation/contract | MSAL multiple-account browser flow, exact native-account binding, account-specific silent acquisition, interaction-required propagation and removal isolation are implemented and channel-tested. | +| PASSED | Microsoft — implementation/contract | MSAL multiple-account browser flow, exact native-account binding, provider-reported grant persistence, account-specific silent acquisition, domain reconnect mapping, independent reminder date/time editing, and removal isolation are implemented and contract-tested. | | BLOCKED | Microsoft — live | A real public client ID/tenant, signer-specific debug/local/Play redirects, disposable personal and organizational accounts, and an Android device are required for consent/browser return/silent worker/removal tests. | -| PASSED | Nextcloud — automated | Existing DAV engines, Login Flow v2 state, capabilities, pending operations, conflict behavior, collection administration and Android contextual-LAN result mapping pass shared/unit coverage; UI consumes concrete collection capabilities. | +| PASSED | Nextcloud — automated | Existing DAV engines, Login Flow v2 state, capabilities, pending operations, conflict behavior, collection administration and Android contextual-LAN mapping pass shared/unit coverage. Android UI consumes concrete collection capabilities and exposes supported sharing/publishing/trash/restore plus advanced VTODO/hierarchy/export operations. | | BLOCKED | Nextcloud — live | A disposable public-HTTPS server account plus an owner-configured LAN endpoint on an Android 17 device are required to verify polling/resume, app-password storage, real calendars/tasks/sharing/publishing/trash/admin and denied-LAN behavior. | | PASSED | Apple and WebCal — automated | Calendar-only/read-only capability enforcement, credential separation, subscription refresh state and meaningful account failure state use the existing tested repositories and Android mobile UI. | | BLOCKED | Apple and WebCal — live | Disposable iCloud credentials with an app-specific password and disposable WebCal endpoints are required to verify revocation, refresh, and private embedded credentials against real services. | -| PASSED | Calendar and tasks — automated | Agenda/day/week/month/year, all-day/timed/date-only items, recurrence/exceptions, due/undated/completed tasks, fields, hierarchy, read-only capabilities, source/account identity and empty/busy/error states are exercised by shared and Android widget tests. | +| PASSED | Calendar and tasks — automated | Virtual Agenda, time-axis Day/Week with all-day/overlap/current-time placement, civil-date Month, and Year miniatures are implemented. Populated Android fixtures verify overnight intersection and source/list hiding. Shared and Android tests cover all-day/timed/date-only items, recurrence/exceptions, due/undated/completed tasks, fields, hierarchy, read-only capabilities, account-qualified destinations, editor discard/recovery behavior, and empty/busy/error states. | | BLOCKED | Calendar and tasks — service round trips | Named destructive workflows for Google, Microsoft, Nextcloud, Apple and WebCal require provider registrations and dedicated QA collections. No owner's real collection was modified. | | PASSED | Offline and conflicts — automated | Cached browsing, supported queued mutations, replay/backoff, ETags, tombstones, preserved ICS properties, interrupted/ambiguous creates and serialized UI/worker account access are covered by the shared suite and account gate. | | BLOCKED | Offline and conflicts — real network | Provider-side state after a dropped create response and reconnect must be observed using disposable remote collections and controlled networking on an Android device. | -| PASSED | Database and preferences — automated | Schema migration through version 18, foreground/background visibility, atomic settings writes, account removal, coordination release and account resurrection protections are covered by database, settings and sync tests. | +| PASSED | Database and preferences — automated | Schema migration through version 18, foreground/background visibility, atomic settings writes, account removal, engine-owned coordination release and account resurrection protections are covered by database, settings, sync and native gate tests. | | BLOCKED | Database and preferences — OS interruption | Kill-during-write, upgrade, and account removal while a real WorkManager job owns the native process require an installed app and device instrumentation. | -| PASSED | Notifications — automated | Durable mapping, horizon/cap, exact/inexact policy, generation/stale-action rejection, edit/cancel/dedup, Open/Snooze/Dismiss persistence, quiet/private/summary logic and independent reconciliation gate pass deterministic tests. Future scheduling is not labeled delivery. | +| PASSED | Notifications — automated | Durable mapping, horizon/cap, exact/inexact policy, generation/stale-action rejection, edit/cancel/dedup, explicit Open UI behavior, Snooze/Dismiss persistence, post-quiet-hours eligibility, pending overdue inexact retention, privacy/precision replacement, private/summary logic, first-reminder permission request and an offline-capable refill worker are covered by deterministic tests/source contracts. Future scheduling is not labeled delivery. | | BLOCKED | Notifications — delivered behavior | A physical phone and signed installed build are required for screen-off timing, process death, permission/channel denial/revocation, reboot, package upgrade, battery idle, exact/inexact observation and cold/warm actions. | | PASSED | Time and recurrence — automated | Shared timezone, DST gap/overlap, all-day/date-only/floating fields, recurrence and exception tests pass; Android system timezone/12–24-hour channel updates feed the app scope. | | BLOCKED | Time and recurrence — system changes | A device is required to change clock/timezone/locale/12–24-hour settings and verify alarm rescheduling across provider/device timezone differences. | -| PASSED | Files and links — automated | ICS preview-before-import, no silent mutation, bounded and sanitized content-URI reads, SAF create/tree operations, malformed/oversized/cancel/duplicate activation handling, geo/browser fallback and missing-handler feedback are implemented and contract-tested. | +| PASSED | Files and links — automated | ICS preview-before-import, no silent mutation, bounded and sanitized content-URI reads, off-main-thread content-provider I/O, SAF create/tree operations, malformed/oversized/cancel/duplicate activation handling, geo/browser fallback and missing-handler feedback are implemented and contract-tested. | | BLOCKED | Files and links — document providers | A device with at least two document providers and maps/browser handlers is required for persisted/revoked grants, picker cancellation, native collection export, and cold-start share/open behavior. | | PASSED | Security and release — source/artifact | Secret-free public examples, credential store separation, private notification payload design, backup exclusions, release manifest, R8, exact permissions, v2 test signature, and APK/AAB 16 KB binary inspection passed. | | BLOCKED | Security and release — production | Owner keystore/CI secret configuration, Play enrollment/app-signing identity, provider console registration, fresh install/upgrade, 16 KB system-image launch, and production-signed inspection are required. | diff --git a/lib/src/android/android_app.dart b/lib/src/android/android_app.dart index f63c0c20..4417b070 100644 --- a/lib/src/android/android_app.dart +++ b/lib/src/android/android_app.dart @@ -26,6 +26,7 @@ class AndroidBusyMaxApp extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final settings = ref.watch(appSettingsControllerProvider); return MaterialApp( + restorationScopeId: 'busymax_android', debugShowCheckedModeBanner: false, onGenerateTitle: (_) => 'BusyMax', locale: settings.locale, diff --git a/lib/src/android/android_authorization.dart b/lib/src/android/android_authorization.dart index c6b5141d..b8052756 100644 --- a/lib/src/android/android_authorization.dart +++ b/lib/src/android/android_authorization.dart @@ -68,7 +68,7 @@ final class AndroidAuthorizationBroker final native = await _platform.authorizeGoogleInteractively( scopes: _googleScopes, ); - final tokenSet = _tokenSet(native, extraScopes: _googleScopes); + final tokenSet = _tokenSet(native); final user = await fetchUserInfo(tokenSet); final subject = user?.subject?.trim(); if (subject == null || subject.isEmpty) { @@ -99,7 +99,7 @@ final class AndroidAuthorizationBroker final native = await _platform.authorizeMicrosoftInteractively( scopes: _microsoftNativeScopes, ); - final tokenSet = _tokenSet(native, extraScopes: _microsoftStoredScopes); + final tokenSet = _tokenSet(native); final user = await _microsoftMe(native.accessToken); if (user.id.trim().isEmpty) { throw const OAuthException( @@ -166,19 +166,25 @@ final class AndroidAuthorizationBroker BusyProvider provider, String accountId, ) async { - final token = switch (provider) { - BusyProvider.google => await _platform.authorizeGoogleSilently( - accountId: accountId, - scopes: _googleScopes, - ), - BusyProvider.microsoft => await _platform.authorizeMicrosoftSilently( - accountId: accountId, - scopes: _microsoftNativeScopes, - ), - _ => throw StateError('$provider does not use native authorization.'), - }; - _lastAccessTokens[accountId] = token.accessToken; - return 'Bearer ${token.accessToken}'; + try { + final token = switch (provider) { + BusyProvider.google => await _platform.authorizeGoogleSilently( + accountId: accountId, + scopes: _googleScopes, + ), + BusyProvider.microsoft => await _platform.authorizeMicrosoftSilently( + accountId: accountId, + scopes: _microsoftNativeScopes, + ), + _ => throw StateError('$provider does not use native authorization.'), + }; + final tokenSet = _tokenSet(token); + _requireSilentScopes(provider, tokenSet); + _lastAccessTokens[accountId] = token.accessToken; + return 'Bearer ${token.accessToken}'; + } on PlatformException catch (error) { + throw _silentOAuthError(error, provider: provider); + } } @override @@ -194,12 +200,18 @@ final class AndroidAuthorizationBroker } Future _googleToken(String accountId) async { - final native = await _platform.authorizeGoogleSilently( - accountId: accountId, - scopes: _googleScopes, - ); - _lastAccessTokens[accountId] = native.accessToken; - return _tokenSet(native, extraScopes: _googleScopes); + try { + final native = await _platform.authorizeGoogleSilently( + accountId: accountId, + scopes: _googleScopes, + ); + final tokenSet = _tokenSet(native); + _requireSilentScopes(BusyProvider.google, tokenSet); + _lastAccessTokens[accountId] = native.accessToken; + return tokenSet; + } on PlatformException catch (error) { + throw _silentOAuthError(error, provider: BusyProvider.google); + } } @override @@ -236,7 +248,15 @@ final class AndroidAuthorizationBroker @override Future clearLocalSession({String? accountId}) async { final target = accountId ?? await activeAccountId; - if (target != null) _lastAccessTokens.remove(target); + if (target != null) { + _lastAccessTokens.remove(target); + if (target.startsWith('google:')) { + await _platform.removeAuthorization( + provider: BusyProvider.google.storageValue, + accountId: target, + ); + } + } if (target == null || await activeAccountId == target) { await _secretStore.clearActiveAccount(); } @@ -254,16 +274,63 @@ final class AndroidAuthorizationBroker @override Future cancelSignIn() => _platform.cancelInteractiveAuthorization(); - OAuthTokenSet _tokenSet( - AndroidAuthorizationToken token, { - Iterable extraScopes = const [], - }) => OAuthTokenSet( + OAuthTokenSet _tokenSet(AndroidAuthorizationToken token) => OAuthTokenSet( accessToken: token.accessToken, expiresAtUtc: token.expiresAtUtc ?? DateTime.now().toUtc().add(const Duration(minutes: 45)), tokenType: 'Bearer', - scopes: {...token.scopes, ...extraScopes}, + // Persist only scopes reported by the authorization provider. Adding the + // requested scopes here would turn a denied permission into a fake grant. + scopes: {for (final scope in token.scopes) _storedScope(scope)}, + ); + + void _requireSilentScopes(BusyProvider provider, OAuthTokenSet tokenSet) { + final granted = tokenSet.scopes; + final complete = switch (provider) { + BusyProvider.google => + granted.contains(googleTasksReadWriteScope) && + granted.contains(googleCalendarReadWriteScope), + BusyProvider.microsoft => _microsoftStoredScopes.every(granted.contains), + _ => true, + }; + if (!complete) { + throw OAuthException( + provider == BusyProvider.microsoft + ? 'MicrosoftOAuthMissingToken' + : 'OAuthMissingToken', + '${provider.displayName} permissions must be reconnected.', + ); + } + } +} + +String _storedScope(String value) { + final scope = value.trim(); + for (final nativeScope in _microsoftNativeScopes) { + if (scope.toLowerCase() == nativeScope.toLowerCase()) { + return 'https://graph.microsoft.com/$nativeScope'; + } + } + return scope; +} + +OAuthException _silentOAuthError( + PlatformException error, { + required BusyProvider provider, +}) { + final reconnect = error.code == 'android/auth-interaction-required'; + if (reconnect) { + return OAuthException( + provider == BusyProvider.microsoft + ? 'MicrosoftOAuthMissingToken' + : 'OAuthMissingToken', + '${provider.displayName} authorization must be reconnected.', + ); + } + return OAuthException( + error.code, + error.message ?? '${provider.displayName} authorization failed.', ); } diff --git a/lib/src/android/android_background.dart b/lib/src/android/android_background.dart index ad8ac720..680d7a8e 100644 --- a/lib/src/android/android_background.dart +++ b/lib/src/android/android_background.dart @@ -22,27 +22,49 @@ import 'android_time_zone.dart'; const busyMaxPeriodicSyncUniqueName = 'busymax.periodic-sync.v1'; const busyMaxPeriodicSyncTaskName = 'busymax.sync-and-reconcile'; const busyMaxImmediateSyncUniqueName = 'busymax.immediate-sync.v1'; +const busyMaxNotificationRefillUniqueName = 'busymax.notification-refill.v1'; +const busyMaxNotificationRefillTaskName = 'busymax.reconcile-notifications'; @pragma('vm:entry-point') void busyMaxWorkmanagerDispatcher() { - Workmanager().executeTask((taskName, inputData) async { - if (taskName != busyMaxPeriodicSyncTaskName) return true; - WidgetsFlutterBinding.ensureInitialized(); - DartPluginRegistrant.ensureInitialized(); - final runtime = await AndroidHeadlessRuntime.create(); - try { - await runtime.container.read(allAccountsSyncRunnerProvider)(); - await runtime.notifications.reconcile(); - await BusyMaxAndroidPlatform.instance.notifyDataChanged(); - return true; - } on Object { - // WorkManager applies bounded exponential backoff. Provider-specific - // failures are already persisted in SyncRuns by the shared engines. - return false; - } finally { - await runtime.dispose(); - } - }); + Workmanager().executeTask( + (taskName, inputData) async { + if (taskName != busyMaxPeriodicSyncTaskName && + taskName != busyMaxNotificationRefillTaskName) { + return true; + } + WidgetsFlutterBinding.ensureInitialized(); + DartPluginRegistrant.ensureInitialized(); + final runtime = await AndroidHeadlessRuntime.create(); + var succeeded = true; + try { + if (taskName == busyMaxPeriodicSyncTaskName) { + try { + await runtime.container.read(allAccountsSyncRunnerProvider)(); + } on Object { + // Provider-specific failures are persisted by the shared engines. + succeeded = false; + } + } + // Local reminders must still refill while offline and after a failed + // provider synchronization. + try { + await runtime.notifications.reconcile(); + await BusyMaxAndroidPlatform.instance.notifyDataChanged(); + } on Object { + succeeded = false; + } + return succeeded; + } finally { + await runtime.dispose(); + } + }, + onTaskStopped: (taskName, stopReason) async { + // Dart finally blocks are not guaranteed when Android destroys the + // worker engine. Release engine-owned native semaphores explicitly. + await BusyMaxAndroidPlatform.instance.releaseOwnedAccountGates(); + }, + ); } Future configureBusyMaxWorkmanager() async { @@ -63,6 +85,20 @@ Future configureBusyMaxWorkmanager() async { backoffPolicyDelay: const Duration(minutes: 10), tag: 'busymax-sync', ); + await workmanager.registerPeriodicTask( + busyMaxNotificationRefillUniqueName, + busyMaxNotificationRefillTaskName, + frequency: const Duration(minutes: 15), + flexInterval: const Duration(minutes: 5), + constraints: Constraints( + requiresBatteryNotLow: true, + requiresStorageNotLow: true, + ), + existingWorkPolicy: ExistingPeriodicWorkPolicy.update, + backoffPolicy: BackoffPolicy.exponential, + backoffPolicyDelay: const Duration(minutes: 10), + tag: 'busymax-notification-refill', + ); } Future enqueueImmediateBusyMaxSync() => Workmanager().registerOneOffTask( diff --git a/lib/src/android/android_notifications.dart b/lib/src/android/android_notifications.dart index 9f253bef..55549f65 100644 --- a/lib/src/android/android_notifications.dart +++ b/lib/src/android/android_notifications.dart @@ -346,38 +346,61 @@ final class AndroidNotificationService Future _reconcileOnce() async { final now = DateTime.now().toUtc(); final horizon = now.add(_schedulingHorizon).millisecondsSinceEpoch; + final nowMillis = now.millisecondsSinceEpoch; final eligibleAccounts = { for (final account in await _database.select(_database.accounts).get()) if (accountLocalReminderEligibleStates.contains(account.authState)) account.id, }; final settings = _settings(); + final mappings = await _database + .select(_database.androidNotificationMappings) + .get(); + final mappingByScheduleId = { + for (final mapping in mappings) mapping.scheduleId: mapping, + }; + final pendingIds = { + for (final request in await _plugin.pendingNotificationRequests()) + request.id, + }; + final exact = await canScheduleExactly(); + final registrationState = androidNotificationRegistrationState( + settings: settings, + exact: exact, + ); final rows = await (_database.select(_database.notificationSchedule)..where( (table) => table.accountId.isIn(eligibleAccounts) & table.sentAtUtc.isNull() & - table.dismissedAtUtc.isNull() & - table.scheduledAtUtc.isSmallerOrEqualValue(horizon), + table.dismissedAtUtc.isNull(), )) .get(); - rows.removeWhere( - (row) => - (row.sourceType == 'event' && !settings.notifyEventReminders) || + rows.removeWhere((row) { + if ((row.sourceType == 'event' && !settings.notifyEventReminders) || (row.sourceType == 'task' && !settings.notifyTaskReminders) || - !const {'event', 'task'}.contains(row.sourceType) || - _effectiveAt(row) <= now.millisecondsSinceEpoch, + !const {'event', 'task'}.contains(row.sourceType)) { + return true; + } + final effectiveAt = applyAndroidQuietHours(_effectiveAt(row), settings); + final mapping = mappingByScheduleId[row.id]; + final remainsPending = + mapping != null && pendingIds.contains(mapping.platformId); + return !shouldKeepAndroidReminder( + effectiveAt: effectiveAt, + now: nowMillis, + horizon: horizon, + remainsPending: remainsPending, + ); + }); + rows.sort( + (a, b) => applyAndroidQuietHours( + _effectiveAt(a), + settings, + ).compareTo(applyAndroidQuietHours(_effectiveAt(b), settings)), ); - rows.sort((a, b) => _effectiveAt(a).compareTo(_effectiveAt(b))); final desired = rows.take(_maximumScheduledAlarms).toList(); final desiredIds = {for (final row in desired) row.id}; - final mappings = await _database - .select(_database.androidNotificationMappings) - .get(); - final pendingIds = { - for (final request in await _plugin.pendingNotificationRequests()) - request.id, - }; for (final mapping in mappings) { if (!desiredIds.contains(mapping.scheduleId)) { @@ -388,7 +411,6 @@ final class AndroidNotificationService } } - final exact = await canScheduleExactly(); _precisionDiagnostic = exact ? 'Exact reminder alarms are enabled.' : 'Exact alarm access is unavailable; reminders use Android inexact scheduling.'; @@ -413,6 +435,7 @@ final class AndroidNotificationService mapping != null && mapping.generation == row.generation && mapping.scheduledAtUtc == effectiveAt && + mapping.state == registrationState && pendingIds.contains(mapping.platformId); if (unchanged) continue; if (mapping != null) await _plugin.cancel(id: mapping.platformId); @@ -427,6 +450,9 @@ final class AndroidNotificationService sourceId: row.sourceId, ).encode(); final strings = _strings(); + final scheduledAt = effectiveAt <= nowMillis + ? now.add(const Duration(seconds: 5)).millisecondsSinceEpoch + : effectiveAt; await _plugin.zonedSchedule( id: platformId, title: @@ -439,7 +465,7 @@ final class AndroidNotificationService : row.body, scheduledDate: tz.TZDateTime.fromMillisecondsSinceEpoch( tz.local, - effectiveAt, + scheduledAt, ), notificationDetails: NotificationDetails( android: AndroidNotificationDetails( @@ -454,22 +480,7 @@ final class AndroidNotificationService NotificationDetailLevel.private ? NotificationVisibility.private : NotificationVisibility.public, - actions: [ - AndroidNotificationAction( - androidNotificationActionOpen, - strings.open, - ), - AndroidNotificationAction( - androidNotificationActionSnooze, - strings.snooze, - cancelNotification: true, - ), - AndroidNotificationAction( - androidNotificationActionDismiss, - strings.dismiss, - cancelNotification: true, - ), - ], + actions: androidReminderNotificationActions(strings), ), ), androidScheduleMode: exact @@ -485,6 +496,7 @@ final class AndroidNotificationService generation: row.generation, platformId: platformId, scheduledAtUtc: effectiveAt, + state: Value(registrationState), updatedAtUtc: now.millisecondsSinceEpoch, ), ); @@ -493,6 +505,7 @@ final class AndroidNotificationService settings: settings, eligibleAccounts: eligibleAccounts, exact: exact, + registrationState: registrationState, pendingIds: pendingIds, ); } @@ -501,6 +514,7 @@ final class AndroidNotificationService required AppSettings settings, required Set eligibleAccounts, required bool exact, + required String registrationState, required Set pendingIds, }) async { const platformId = 0x425903; @@ -585,6 +599,7 @@ final class AndroidNotificationService current != null && current.taskCount == count && current.scheduledAtUtc == scheduledAt && + current.state == registrationState && pendingIds.contains(platformId); if (unchanged) return; if (current != null) await _plugin.cancel(id: platformId); @@ -612,12 +627,7 @@ final class AndroidNotificationService NotificationDetailLevel.private ? NotificationVisibility.private : NotificationVisibility.public, - actions: [ - AndroidNotificationAction( - androidNotificationActionOpen, - strings.open, - ), - ], + actions: [androidOpenNotificationAction(strings)], ), ), androidScheduleMode: exact @@ -640,6 +650,7 @@ final class AndroidNotificationService generation: generation, scheduledAtUtc: scheduledAt, taskCount: count, + state: Value(registrationState), updatedAtUtc: DateTime.now().millisecondsSinceEpoch, ), ); @@ -698,6 +709,46 @@ int applyAndroidQuietHours(int epochMillis, AppSettings settings) { ).millisecondsSinceEpoch; } +/// Fingerprint of platform-visible scheduling choices that are not represented +/// by a reminder generation. A changed fingerprint must replace the existing +/// Android registration even when the source reminder itself did not change. +String androidNotificationRegistrationState({ + required AppSettings settings, + required bool exact, +}) => + 'scheduled:v2:${settings.notificationDetailLevel.name}:${exact ? 'exact' : 'inexact'}'; + +AndroidNotificationAction androidOpenNotificationAction( + AndroidNotificationStrings strings, +) => AndroidNotificationAction( + androidNotificationActionOpen, + strings.open, + showsUserInterface: true, +); + +List androidReminderNotificationActions( + AndroidNotificationStrings strings, +) => [ + androidOpenNotificationAction(strings), + AndroidNotificationAction( + androidNotificationActionSnooze, + strings.snooze, + cancelNotification: true, + ), + AndroidNotificationAction( + androidNotificationActionDismiss, + strings.dismiss, + cancelNotification: true, + ), +]; + +bool shouldKeepAndroidReminder({ + required int effectiveAt, + required int now, + required int horizon, + required bool remainsPending, +}) => effectiveAt <= horizon && (effectiveAt > now || remainsPending); + bool _isQuietNow(AppSettings settings) { final now = DateTime.now().millisecondsSinceEpoch; return applyAndroidQuietHours(now, settings) != now; diff --git a/lib/src/android/presentation/android_schedule_screen.dart b/lib/src/android/presentation/android_schedule_screen.dart index ee1bbd54..27c3f29c 100644 --- a/lib/src/android/presentation/android_schedule_screen.dart +++ b/lib/src/android/presentation/android_schedule_screen.dart @@ -1,8 +1,8 @@ import 'dart:async'; import 'dart:convert'; -import 'dart:typed_data'; import 'package:busymax_android_platform/busymax_android_platform.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:intl/intl.dart'; @@ -13,6 +13,7 @@ import '../../features/calendar/data/calendar_repository.dart'; import '../../features/calendar/presentation/event_editor_draft.dart'; import '../../features/maps/application/external_location_launcher.dart'; import '../../features/schedule/application/saved_schedule_location.dart'; +import '../../features/tasks/data/tasks_repository.dart'; import '../../features/recurrence/domain/event_recurrence_codec.dart'; import '../../features/recurrence/domain/recurrence_rule.dart'; import '../../l10n/l10n.dart'; @@ -20,8 +21,10 @@ import '../../l10n/time_format_scope.dart'; import '../../providers/busy_provider.dart'; import '../../schedule/schedule_filters.dart'; import '../../schedule/schedule_item.dart'; +import '../../schedule/schedule_projection.dart'; import '../../schedule/schedule_range.dart'; import '../../schedule/schedule_view_mode.dart'; +import '../android_notifications.dart'; import 'android_settings_screen.dart'; import 'android_tasks_screen.dart'; @@ -31,12 +34,32 @@ final _androidScheduleItemsProvider = FutureProvider.autoDispose ({DateTime anchor, ScheduleViewMode mode, String query}) >((ref, key) async { ref.watch(scheduleDataRevisionProvider); + final sources = await ref.watch(calendarSourcesStreamProvider.future); + final lists = await ref.watch(scheduleTaskListsProvider.future); + final settings = ref.watch(appSettingsControllerProvider); return ref .watch(scheduleRepositoryProvider) .listItems( range: _rangeFor(key.anchor, key.mode), filters: ScheduleFilters( query: key.query, + sourceIds: { + for (final source in sources) + if (source.selected) source.id, + }, + taskListKeys: { + for (final list in lists) + if (settings.isTaskListVisibleInSchedule( + list.accountId, + list.id, + )) + ScheduleTaskListKey( + accountId: list.accountId, + taskListId: list.id, + ), + }, + sourceFilterActive: true, + taskListFilterActive: true, showCompletedTasks: true, showNoDateTasks: key.mode == ScheduleViewMode.agenda, ), @@ -163,14 +186,16 @@ class _AndroidScheduleScreenState extends ConsumerState { title: context.l10n.scheduleUnavailable, detail: '$error', ), - data: (value) => value.isEmpty + data: (value) => + value.isEmpty && + (_query.isNotEmpty || mode == ScheduleViewMode.agenda) ? _Message( icon: _query.isEmpty ? Icons.event_busy : Icons.search_off, title: _query.isEmpty - ? context.l10n.scheduleNoSources + ? context.l10n.noEventsOrTasks : context.l10n.scheduleNoSearchResults, detail: _query.isEmpty - ? context.l10n.scheduleNoSourcesDescription + ? context.l10n.noEventsOrTasks : context.l10n.scheduleNoSearchResultsDescription, ) : _ScheduleBody( @@ -178,10 +203,17 @@ class _AndroidScheduleScreenState extends ConsumerState { mode: mode, items: value, onSelectDate: (date) => setState(() => _anchor = date), + onModeChanged: (value) => unawaited( + ref + .read(appSettingsControllerProvider.notifier) + .setAndroidScheduleViewMode(value), + ), onOpen: (item) => _showItem(context, item), + onToggleTask: _toggleTask, ), ), floatingActionButton: FloatingActionButton( + heroTag: 'android-schedule-add', onPressed: () => _createItem(context), tooltip: context.l10n.create, child: const Icon(Icons.add), @@ -198,6 +230,12 @@ class _AndroidScheduleScreenState extends ConsumerState { builder: (context, ref, _) { final sources = ref.watch(calendarSourcesStreamProvider); final lists = ref.watch(scheduleTaskListsProvider); + final accounts = + ref.watch(accountsStreamProvider).valueOrNull ?? + const []; + final accountLabels = { + for (final account in accounts) account.id: account.displayLabel, + }; final settings = ref.watch(appSettingsControllerProvider); return SafeArea( child: SizedBox( @@ -209,22 +247,27 @@ class _AndroidScheduleScreenState extends ConsumerState { title: Text(context.l10n.collectionSettings), titleTextStyle: Theme.of(context).textTheme.titleLarge, ), - ...(sources.valueOrNull ?? const []) - .map( - (source) => CheckboxListTile( - secondary: const Icon(Icons.calendar_month), - title: Text(source.summary), - subtitle: Text(source.provider.displayName), - value: source.selected, - onChanged: (selected) => ref - .read(calendarRepositoryProvider) - .setSourceSelected(source.id, selected == true), - ), + ...(sources.valueOrNull ?? const []).map( + (source) => CheckboxListTile( + secondary: const Icon(Icons.calendar_month), + title: Text(source.summary), + subtitle: Text( + '${source.provider.displayName} · ' + '${source.authenticatedAccountEmail ?? source.accountId}', ), + value: source.selected, + onChanged: (selected) => ref + .read(calendarRepositoryProvider) + .setSourceSelected(source.id, selected == true), + ), + ), ...(lists.valueOrNull ?? const []).map( (list) => CheckboxListTile( secondary: const Icon(Icons.checklist), title: Text(list.title), + subtitle: Text( + accountLabels[list.accountId] ?? list.accountId, + ), value: settings.isTaskListVisibleInSchedule( list.accountId, list.id, @@ -310,6 +353,9 @@ class _AndroidScheduleScreenState extends ConsumerState { context, lists, title: context.l10n.newTask, + accountLabels: { + for (final account in accounts) account.id: account.displayLabel, + }, ); if (list == null || !context.mounted) return; final account = accounts @@ -462,7 +508,10 @@ class _AndroidScheduleScreenState extends ConsumerState { ListTile( leading: const Icon(Icons.calendar_today_outlined), title: Text(candidate.summary), - subtitle: Text(candidate.provider.displayName), + subtitle: Text( + '${candidate.provider.displayName} · ' + '${candidate.authenticatedAccountEmail ?? candidate.accountId}', + ), onTap: () => Navigator.pop(sheetContext, candidate), ), ], @@ -494,6 +543,27 @@ class _AndroidScheduleScreenState extends ConsumerState { ) async { await showAndroidEventEditor(context, ref, eventId: item.id); } + + Future _toggleTask(TaskScheduleItem task) async { + if (!task.capabilities.canEdit) return; + try { + await ref + .read(tasksRepositoryForAccountProvider(task.accountId)) + .patchTask( + task.sourceId, + task.id, + TaskPatchInput({ + 'status': task.completed ? 'needsAction' : 'completed', + }), + ); + } on Object catch (error) { + if (mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('$error'))); + } + } + } } Future showAndroidEventEditor( @@ -505,11 +575,8 @@ Future showAndroidEventEditor( .read(calendarRepositoryProvider) .loadEventDetail(eventId); if (!context.mounted || detail == null) return; - final sources = - (ref.read(calendarSourcesStreamProvider).valueOrNull ?? - const []) - .where((source) => source.capabilities.canCreateEvents) - .toList(); + final sources = await ref.read(calendarSourcesStreamProvider.future); + if (!context.mounted) return; await Navigator.of(context).push( MaterialPageRoute( fullscreenDialog: true, @@ -530,13 +597,17 @@ class _ScheduleBody extends StatelessWidget { required this.mode, required this.items, required this.onSelectDate, + required this.onModeChanged, required this.onOpen, + required this.onToggleTask, }); final DateTime anchor; final ScheduleViewMode mode; final List items; final ValueChanged onSelectDate; + final ValueChanged onModeChanged; final ValueChanged onOpen; + final ValueChanged onToggleTask; @override Widget build(BuildContext context) => switch (mode) { @@ -545,238 +616,519 @@ class _ScheduleBody extends StatelessWidget { items: items, onSelectDate: onSelectDate, onOpen: onOpen, + onToggleTask: onToggleTask, ), ScheduleViewMode.year => _YearView( anchor: anchor, items: items, onSelectDate: onSelectDate, + onModeChanged: onModeChanged, ), - ScheduleViewMode.week => _WeekView( + ScheduleViewMode.week || ScheduleViewMode.day => _AndroidTimeGrid( + key: ValueKey('android-${mode.name}-time-grid'), anchor: anchor, + days: mode == ScheduleViewMode.day ? 1 : 7, items: items, onOpen: onOpen, ), - ScheduleViewMode.day => _AgendaList( - items: items - .where((item) => item.start != null && _sameDay(item.start!, anchor)) - .toList(), + ScheduleViewMode.agenda => _AgendaList( + items: items, onOpen: onOpen, + onToggleTask: onToggleTask, ), - ScheduleViewMode.agenda => _AgendaList(items: items, onOpen: onOpen), }; } class _AgendaList extends StatelessWidget { - const _AgendaList({required this.items, required this.onOpen}); + const _AgendaList({ + required this.items, + required this.onOpen, + required this.onToggleTask, + }); final List items; final ValueChanged onOpen; + final ValueChanged onToggleTask; @override - Widget build(BuildContext context) => ListView.separated( - padding: const EdgeInsets.fromLTRB(12, 8, 12, 96), - itemCount: items.length, - separatorBuilder: (_, _) => const SizedBox(height: 6), - itemBuilder: (context, index) { - final item = items[index]; - final time = item.start == null - ? context.l10n.noDate - : item.allDay - ? context.l10n.allDay - : BusyMaxTimeFormatScope.of(context).format(item.start!); - return Card( - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - if (index == 0 || - (item.start == null && items[index - 1].start != null) || - (item.start != null && - (items[index - 1].start == null || - !_sameDay(item.start!, items[index - 1].start!)))) - Padding( - padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), - child: Text( - item.start == null - ? context.l10n.noDate - : DateFormat.yMMMMEEEEd().format(item.start!), - style: Theme.of(context).textTheme.labelLarge, + Widget build(BuildContext context) { + if (items.isEmpty) { + return Center(child: Text(context.l10n.noEventsOrTasks)); + } + return ListView.separated( + padding: const EdgeInsets.fromLTRB(12, 8, 12, 96), + itemCount: items.length, + separatorBuilder: (_, _) => const SizedBox(height: 6), + itemBuilder: (context, index) { + final item = items[index]; + final time = item.start == null + ? context.l10n.noDate + : item.allDay + ? context.l10n.allDay + : BusyMaxTimeFormatScope.of(context).format(item.start!); + final isToday = + item.start != null && _sameDay(item.start!, DateTime.now()); + return Card( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (index == 0 || + (item.start == null && items[index - 1].start != null) || + (item.start != null && + (items[index - 1].start == null || + !_sameDay(item.start!, items[index - 1].start!)))) + Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), + child: Text( + item.start == null + ? context.l10n.noDate + : '${isToday ? '${context.l10n.today} · ' : ''}' + '${DateFormat.yMMMMEEEEd().format(item.start!)}', + style: Theme.of(context).textTheme.labelLarge?.copyWith( + color: isToday + ? Theme.of(context).colorScheme.primary + : null, + ), + ), ), + ListTile( + minVerticalPadding: 12, + leading: item is TaskScheduleItem + ? IconButton( + tooltip: item.completed + ? context.l10n.taskStatusCompleted + : context.l10n.taskStatusInProcess, + onPressed: item.capabilities.canEdit + ? () => onToggleTask(item) + : null, + icon: Icon( + item.completed + ? Icons.check_circle + : Icons.radio_button_unchecked, + ), + ) + : const Icon(Icons.event), + title: Text( + item.title, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + subtitle: Text( + '$time · ${item.sourceName ?? item.provider.displayName}', + ), + onTap: () => onOpen(item), ), - ListTile( - minVerticalPadding: 12, - leading: item is TaskScheduleItem - ? Icon( - item.completed - ? Icons.check_circle - : Icons.radio_button_unchecked, - ) - : const Icon(Icons.event), - title: Text( - item.title, - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - subtitle: Text( - '$time · ${item.sourceName ?? item.provider.displayName}', - ), - onTap: () => onOpen(item), - ), - ], - ), - ); - }, - ); + ], + ), + ); + }, + ); + } } -class _WeekView extends StatelessWidget { - const _WeekView({ +class _AndroidTimeGrid extends StatelessWidget { + const _AndroidTimeGrid({ + super.key, required this.anchor, + required this.days, required this.items, required this.onOpen, }); + + static const _axisWidth = 54.0; + static const _minuteHeight = .72; + final DateTime anchor; + final int days; final List items; final ValueChanged onOpen; + @override Widget build(BuildContext context) { - final start = ScheduleRange.week(anchor).start; + final start = days == 1 + ? DateTime(anchor.year, anchor.month, anchor.day) + : ScheduleRange.week(anchor).start; + final dates = [ + for (var index = 0; index < days; index++) + DateTime(start.year, start.month, start.day + index), + ]; return LayoutBuilder( builder: (context, constraints) { - final shown = constraints.maxWidth >= 700 ? 7 : 3; - final offset = constraints.maxWidth >= 700 - ? 0 - : (anchor.difference(start).inDays).clamp(0, 4); - return Row( - children: [ - for (var index = offset; index < offset + shown; index++) - Expanded( - child: Container( - decoration: BoxDecoration( - border: Border( - left: BorderSide(color: Theme.of(context).dividerColor), - ), + final visibleColumns = constraints.maxWidth >= 700 + ? days + : (days < 3 ? days : 3); + final availableWidth = constraints.maxWidth - _axisWidth; + final columnWidth = days == 1 + ? availableWidth.clamp(112.0, double.infinity).toDouble() + : (availableWidth / visibleColumns.clamp(1, 7)) + .clamp(112.0, 220.0) + .toDouble(); + final contentWidth = _axisWidth + columnWidth * days; + return SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: SizedBox( + width: contentWidth, + height: constraints.maxHeight, + child: Column( + children: [ + SizedBox( + height: 48, + child: Row( + children: [ + const SizedBox(width: _axisWidth), + for (final day in dates) + SizedBox( + width: columnWidth, + child: Center( + child: Text( + DateFormat.E().add_d().format(day), + style: Theme.of(context).textTheme.titleSmall, + ), + ), + ), + ], ), - child: Column( + ), + SizedBox( + height: 58, + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Padding( - padding: const EdgeInsets.all(8), - child: Text( - DateFormat.E().add_d().format( - start.add(Duration(days: index)), + SizedBox( + width: _axisWidth, + child: Center( + child: Text( + context.l10n.allDay, + style: Theme.of(context).textTheme.labelSmall, ), - style: Theme.of(context).textTheme.titleSmall, ), ), - Expanded( - child: _AgendaList( - items: items - .where( - (item) => - item.start != null && - _sameDay( - item.start!, - start.add(Duration(days: index)), - ), - ) - .toList(), - onOpen: onOpen, + for (final day in dates) + SizedBox( + width: columnWidth, + child: _AndroidAllDayCell( + items: ScheduleProjection.itemsForDay( + items, + day, + ).where((item) => item.allDay).toList(), + onOpen: onOpen, + ), ), - ), ], ), ), - ), - ], + const Divider(height: 1), + Expanded( + child: SingleChildScrollView( + child: SizedBox( + height: 24 * 60 * _minuteHeight, + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + SizedBox( + width: _axisWidth, + child: Stack( + children: [ + for (var hour = 0; hour < 24; hour++) + PositionedDirectional( + top: hour * 60 * _minuteHeight - 8, + end: 6, + child: Text( + BusyMaxTimeFormatScope.of( + context, + ).format(DateTime(2026, 1, 1, hour)), + style: Theme.of( + context, + ).textTheme.labelSmall, + ), + ), + ], + ), + ), + for (final day in dates) + SizedBox( + width: columnWidth, + child: _AndroidTimedDayColumn( + day: day, + items: ScheduleProjection.itemsForDay( + items, + day, + ).where((item) => !item.allDay).toList(), + minuteHeight: _minuteHeight, + onOpen: onOpen, + ), + ), + ], + ), + ), + ), + ), + ], + ), + ), ); }, ); } } +class _AndroidAllDayCell extends StatelessWidget { + const _AndroidAllDayCell({required this.items, required this.onOpen}); + + final List items; + final ValueChanged onOpen; + + @override + Widget build(BuildContext context) => Container( + padding: const EdgeInsets.all(3), + decoration: BoxDecoration( + border: BorderDirectional( + start: BorderSide(color: Theme.of(context).dividerColor, width: .5), + ), + ), + child: items.isEmpty + ? null + : Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _AndroidCalendarChip(item: items.first, onOpen: onOpen), + if (items.length > 1) + Text( + '+${items.length - 1}', + textAlign: TextAlign.end, + style: Theme.of(context).textTheme.labelSmall, + ), + ], + ), + ); +} + +class _AndroidTimedDayColumn extends StatelessWidget { + const _AndroidTimedDayColumn({ + required this.day, + required this.items, + required this.minuteHeight, + required this.onOpen, + }); + + final DateTime day; + final List items; + final double minuteHeight; + final ValueChanged onOpen; + + @override + Widget build(BuildContext context) { + final placements = _timedPlacements(items, day); + final laneCount = placements.fold( + 1, + (maximum, placement) => + placement.lane + 1 > maximum ? placement.lane + 1 : maximum, + ); + final now = DateTime.now(); + return LayoutBuilder( + builder: (context, constraints) => DecoratedBox( + decoration: BoxDecoration( + border: BorderDirectional( + start: BorderSide(color: Theme.of(context).dividerColor, width: .5), + ), + ), + child: Stack( + clipBehavior: Clip.hardEdge, + children: [ + for (var hour = 0; hour < 24; hour++) + Positioned( + top: hour * 60 * minuteHeight, + left: 0, + right: 0, + child: Divider( + height: 1, + color: Theme.of(context).dividerColor.withValues(alpha: .55), + ), + ), + for (final placement in placements) + Positioned( + top: placement.startMinute * minuteHeight + 1, + left: placement.lane * constraints.maxWidth / laneCount + 2, + width: constraints.maxWidth / laneCount - 4, + height: + ((placement.endMinute - placement.startMinute) * + minuteHeight) + .clamp(28.0, 24 * 60 * minuteHeight) + .toDouble(), + child: _AndroidCalendarChip( + item: placement.item, + onOpen: onOpen, + showTime: true, + ), + ), + if (_sameDay(now, day)) + Positioned( + top: (now.hour * 60 + now.minute) * minuteHeight, + left: 0, + right: 0, + child: Container( + height: 2, + color: Theme.of(context).colorScheme.error, + ), + ), + ], + ), + ), + ); + } +} + +class _AndroidCalendarChip extends StatelessWidget { + const _AndroidCalendarChip({ + required this.item, + required this.onOpen, + this.showTime = false, + }); + + final ScheduleItem item; + final ValueChanged onOpen; + final bool showTime; + + @override + Widget build(BuildContext context) { + final color = ScheduleProjection.colorForItem( + item, + Theme.of(context).brightness, + ); + return Material( + color: color.withValues(alpha: .2), + borderRadius: BorderRadius.circular(5), + child: InkWell( + borderRadius: BorderRadius.circular(5), + onTap: () => onOpen(item), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 3), + child: Text( + showTime && item.start != null + ? '${BusyMaxTimeFormatScope.of(context).format(item.start!)} ${item.title}' + : item.title, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: Theme.of(context).colorScheme.onSurface, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + ); + } +} + +final class _AndroidTimedPlacement { + const _AndroidTimedPlacement({ + required this.item, + required this.startMinute, + required this.endMinute, + required this.lane, + }); + + final ScheduleItem item; + final int startMinute; + final int endMinute; + final int lane; +} + +List<_AndroidTimedPlacement> _timedPlacements( + List items, + DateTime day, +) { + final dayStart = DateTime(day.year, day.month, day.day); + final dayEnd = DateTime(day.year, day.month, day.day + 1); + final candidates = <({ScheduleItem item, int start, int end})>[]; + for (final item in items) { + final rawStart = item.start; + if (rawStart == null) continue; + final rawEnd = item.end != null && item.end!.isAfter(rawStart) + ? item.end! + : rawStart.add(const Duration(minutes: 30)); + final start = rawStart.isAfter(dayStart) ? rawStart : dayStart; + final end = rawEnd.isBefore(dayEnd) ? rawEnd : dayEnd; + if (!end.isAfter(start)) continue; + final startMinute = start == dayStart ? 0 : start.hour * 60 + start.minute; + final endMinute = end == dayEnd ? 24 * 60 : end.hour * 60 + end.minute; + candidates.add((item: item, start: startMinute, end: endMinute)); + } + candidates.sort((a, b) { + final byStart = a.start.compareTo(b.start); + return byStart != 0 ? byStart : b.end.compareTo(a.end); + }); + final laneEnds = []; + final result = <_AndroidTimedPlacement>[]; + for (final candidate in candidates) { + var lane = laneEnds.indexWhere((end) => end <= candidate.start); + if (lane < 0) { + lane = laneEnds.length; + laneEnds.add(candidate.end); + } else { + laneEnds[lane] = candidate.end; + } + result.add( + _AndroidTimedPlacement( + item: candidate.item, + startMinute: candidate.start, + endMinute: candidate.end, + lane: lane, + ), + ); + } + return result; +} + class _MonthView extends StatelessWidget { const _MonthView({ required this.anchor, required this.items, required this.onSelectDate, required this.onOpen, + required this.onToggleTask, }); final DateTime anchor; final List items; final ValueChanged onSelectDate; final ValueChanged onOpen; + final ValueChanged onToggleTask; @override Widget build(BuildContext context) { - final range = ScheduleRange.month(anchor); - final days = range.end.difference(range.start).inDays; return LayoutBuilder( builder: (context, constraints) { - final gridHeight = constraints.maxHeight * .58; - return Column( - children: [ - SizedBox( - height: gridHeight, - child: GridView.builder( - physics: const NeverScrollableScrollPhysics(), - gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 7, + return GestureDetector( + behavior: HitTestBehavior.opaque, + onHorizontalDragEnd: (details) { + final velocity = details.primaryVelocity ?? 0; + if (velocity.abs() < 180) return; + onSelectDate( + DateTime(anchor.year, anchor.month + (velocity < 0 ? 1 : -1)), + ); + }, + child: Column( + children: [ + Expanded( + flex: 3, + child: Padding( + padding: const EdgeInsets.fromLTRB(8, 8, 8, 0), + child: _AndroidMonthGrid( + key: const ValueKey('android-month-grid'), + displayedMonth: DateTime(anchor.year, anchor.month), + selectedDate: anchor, + items: items, + onDaySelected: onSelectDate, + ), ), - itemCount: days, - itemBuilder: (context, index) { - final day = range.start.add(Duration(days: index)); - final count = items - .where( - (item) => - item.start != null && _sameDay(item.start!, day), - ) - .length; - final selected = _sameDay(day, anchor); - return InkWell( - onTap: () => onSelectDate(day), - child: Container( - decoration: BoxDecoration( - color: selected - ? Theme.of(context).colorScheme.secondaryContainer - : null, - border: Border.all( - color: Theme.of(context).dividerColor, - width: .4, - ), - ), - padding: const EdgeInsets.all(4), - child: Column( - children: [ - Text( - '${day.day}', - style: TextStyle( - color: day.month == anchor.month - ? null - : Theme.of(context).disabledColor, - ), - ), - if (count > 0) - Text( - '•' * count.clamp(1, 3), - style: TextStyle( - color: Theme.of(context).colorScheme.primary, - ), - ), - ], - ), - ), - ); - }, ), - ), - Expanded( - child: _AgendaList( - items: items - .where( - (item) => - item.start != null && _sameDay(item.start!, anchor), - ) - .toList(), - onOpen: onOpen, + Expanded( + flex: 2, + child: _AgendaList( + items: ScheduleProjection.itemsForDay(items, anchor), + onOpen: onOpen, + onToggleTask: onToggleTask, + ), ), - ), - ], + ], + ), ); }, ); @@ -788,39 +1140,52 @@ class _YearView extends StatelessWidget { required this.anchor, required this.items, required this.onSelectDate, + required this.onModeChanged, }); final DateTime anchor; final List items; final ValueChanged onSelectDate; + final ValueChanged onModeChanged; @override Widget build(BuildContext context) => GridView.builder( + key: const ValueKey('android-year-grid'), padding: const EdgeInsets.fromLTRB(12, 8, 12, 96), - gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: MediaQuery.sizeOf(context).width >= 700 ? 3 : 2, - childAspectRatio: 1.2, + gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent( + maxCrossAxisExtent: 330, + mainAxisExtent: 245, crossAxisSpacing: 8, mainAxisSpacing: 8, ), itemCount: 12, itemBuilder: (context, index) { - final month = index + 1; - final count = items.where((item) => item.start?.month == month).length; + final month = DateTime(anchor.year, index + 1); return Card( + clipBehavior: Clip.antiAlias, child: InkWell( - onTap: () => onSelectDate(DateTime(anchor.year, month, 1)), + onTap: () { + onSelectDate(month); + onModeChanged(ScheduleViewMode.month); + }, child: Padding( - padding: const EdgeInsets.all(16), + padding: const EdgeInsets.all(8), child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Text( - DateFormat.MMMM().format(DateTime(anchor.year, month)), - style: Theme.of(context).textTheme.titleMedium, + DateFormat.MMMM().format(month), + style: Theme.of(context).textTheme.titleSmall, ), - const Spacer(), - Text( - context.l10n.scheduleItemCount(count), - style: Theme.of(context).textTheme.bodyLarge, + const SizedBox(height: 4), + Expanded( + child: IgnorePointer( + child: _AndroidMonthGrid( + displayedMonth: month, + selectedDate: anchor, + items: items, + onDaySelected: (_) {}, + compact: true, + ), + ), ), ], ), @@ -831,6 +1196,153 @@ class _YearView extends StatelessWidget { ); } +class _AndroidMonthGrid extends StatelessWidget { + const _AndroidMonthGrid({ + super.key, + required this.displayedMonth, + required this.selectedDate, + required this.items, + required this.onDaySelected, + this.compact = false, + }); + + final DateTime displayedMonth; + final DateTime selectedDate; + final List items; + final ValueChanged onDaySelected; + final bool compact; + + @override + Widget build(BuildContext context) { + final firstWeekday = _firstWeekday(context); + final month = DateTime(displayedMonth.year, displayedMonth.month); + final firstOffset = (month.weekday - firstWeekday) % DateTime.daysPerWeek; + final gridStart = DateTime(month.year, month.month, 1 - firstOffset); + final days = [ + for (var index = 0; index < 42; index++) + DateTime(gridStart.year, gridStart.month, gridStart.day + index), + ]; + final weekdays = [ + for (var index = 0; index < 7; index++) + ((firstWeekday + index - 1) % 7) + 1, + ]; + return Column( + children: [ + SizedBox( + height: compact ? 16 : 22, + child: Row( + children: [ + for (final weekday in weekdays) + Expanded( + child: Center( + child: Text( + DateFormat.E().format(DateTime(2026, 1, 5 + weekday - 1)), + maxLines: 1, + style: compact + ? Theme.of(context).textTheme.labelSmall + : Theme.of(context).textTheme.labelMedium, + ), + ), + ), + ], + ), + ), + for (var row = 0; row < 6; row++) + Expanded( + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + for (var column = 0; column < 7; column++) + Expanded( + child: _AndroidMonthDay( + day: days[row * 7 + column], + displayedMonth: month, + selectedDate: selectedDate, + items: items, + compact: compact, + onSelected: onDaySelected, + ), + ), + ], + ), + ), + ], + ); + } +} + +class _AndroidMonthDay extends StatelessWidget { + const _AndroidMonthDay({ + required this.day, + required this.displayedMonth, + required this.selectedDate, + required this.items, + required this.compact, + required this.onSelected, + }); + + final DateTime day; + final DateTime displayedMonth; + final DateTime selectedDate; + final List items; + final bool compact; + final ValueChanged onSelected; + + @override + Widget build(BuildContext context) { + final matching = ScheduleProjection.itemsForDay(items, day); + final selected = _sameDay(day, selectedDate); + final scheme = Theme.of(context).colorScheme; + return InkWell( + onTap: () => onSelected(day), + child: DecoratedBox( + decoration: BoxDecoration( + color: selected ? scheme.secondaryContainer : null, + border: compact + ? null + : Border.all(color: Theme.of(context).dividerColor, width: .35), + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + '${day.day}', + style: + (compact + ? Theme.of(context).textTheme.labelSmall + : Theme.of(context).textTheme.bodyMedium) + ?.copyWith( + color: day.month == displayedMonth.month + ? null + : scheme.onSurfaceVariant.withValues(alpha: .45), + ), + ), + if (matching.isNotEmpty) + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + for (final item in matching.take(compact ? 2 : 3)) + Container( + width: compact ? 3 : 5, + height: compact ? 3 : 5, + margin: const EdgeInsets.symmetric(horizontal: 1), + decoration: BoxDecoration( + shape: BoxShape.circle, + color: ScheduleProjection.colorForItem( + item, + Theme.of(context).brightness, + ), + ), + ), + ], + ), + ], + ), + ), + ); + } +} + class AndroidEventEditor extends ConsumerStatefulWidget { const AndroidEventEditor({ super.key, @@ -865,15 +1377,36 @@ class _AndroidEventEditorState extends ConsumerState { ); int? _reminderMinutes; late RecurrenceFrequency _frequency = EventRecurrenceCodec.decode( - _source.provider, + _provider, _draft.recurrence, baseDate: _draft.start, ).frequency; bool _recurrenceChanged = false; bool _saving = false; + bool _allowPop = false; + bool _recoveryLoaded = false; + Timer? _recoveryTimer; + + String get _recoveryKey => + 'busymax.android.event-draft.${widget.draft.accountId}.' + '${widget.draft.eventId ?? 'new-${widget.draft.sourceId}'}'; + + @override + void initState() { + super.initState(); + unawaited(_restoreRecovery()); + _recoveryTimer = Timer.periodic( + const Duration(seconds: 1), + (_) => unawaited(_persistRecovery()), + ); + } @override void dispose() { + _recoveryTimer?.cancel(); + if (_recoveryLoaded && _hasPendingEdits && !_allowPop) { + unawaited(_persistRecovery()); + } _title.dispose(); _location.dispose(); _description.dispose(); @@ -882,187 +1415,253 @@ class _AndroidEventEditorState extends ConsumerState { super.dispose(); } - CalendarSourceEntity get _source => widget.sources.firstWhere( - (source) => source.id == _draft.sourceId, - orElse: () => widget.sources.first, - ); + CalendarSourceEntity? get _source => widget.sources + .where((source) => source.id == _draft.sourceId) + .firstOrNull; + + BusyProvider get _provider => + _source?.provider ?? + _draft.originalDetail?.provider ?? + BusyProvider.google; + + bool get _canEdit { + final source = _source; + if (source == null) return false; + return _draft.eventId == null + ? source.capabilities.canCreateEvents + : source.capabilities.canEditEvents; + } + + bool get _canDelete => + _draft.eventId != null && + (_source?.capabilities.canDeleteEvents ?? false); + + bool get _hasPendingEdits => + _draft != widget.draft || + _guests.text != + widget.draft.attendees + .where((attendee) => !attendee.self && !attendee.organizer) + .map((attendee) => attendee.email) + .join(', ') || + _categories.text != widget.draft.categories.join(', ') || + _reminderMinutes != null || + _recurrenceChanged; @override Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - leading: const CloseButton(), - title: Text( - _draft.eventId == null - ? context.l10n.newEvent - : context.l10n.editEvent, - ), - actions: [ - TextButton( - onPressed: _saving ? null : _save, - child: Text(context.l10n.save), + return PopScope( + canPop: _allowPop || !_hasPendingEdits, + onPopInvokedWithResult: (didPop, _) async { + if (didPop || _saving || _allowPop) return; + if (await _confirmDiscardChanges() && mounted) { + await _clearRecovery(); + if (!mounted) return; + setState(() => _allowPop = true); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) Navigator.pop(context); + }); + } + }, + child: Scaffold( + appBar: AppBar( + leading: IconButton( + tooltip: MaterialLocalizations.of(context).closeButtonTooltip, + onPressed: _saving ? null : _requestClose, + icon: const Icon(Icons.close), ), - ], - ), - body: Form( - child: ListView( - padding: const EdgeInsets.all(16), - children: [ - TextFormField( - controller: _title, - decoration: InputDecoration(labelText: context.l10n.title), - autofocus: _draft.eventId == null, - onChanged: (v) => - setState(() => _draft = _draft.copyWith(title: v)), - ), - const SizedBox(height: 12), - DropdownButtonFormField( - initialValue: _draft.sourceId, - decoration: InputDecoration(labelText: context.l10n.calendar), - items: [ - for (final source in widget.sources) - DropdownMenuItem( - value: source.id, - child: Text(source.summary), - ), - ], - onChanged: _draft.eventId != null - ? null - : (id) { - final source = widget.sources.firstWhere( - (s) => s.id == id, - ); - setState( - () => _draft = _draft.copyWith( - accountId: source.accountId, - sourceId: source.id, - providerCalendarId: source.providerCalendarId, - ), - ); - }, - ), - SwitchListTile( - contentPadding: EdgeInsets.zero, - title: Text(context.l10n.allDay), - value: _draft.allDay, - onChanged: (value) => - setState(() => _draft = _draft.copyWith(allDay: value)), - ), - _DateTimeTile( - label: context.l10n.startDateTime, - value: _draft.start!, - allDay: _draft.allDay, - onChanged: (value) => - setState(() => _draft = _draft.copyWith(start: value)), - ), - _DateTimeTile( - label: context.l10n.endDateTime, - value: _draft.end!, - allDay: _draft.allDay, - onChanged: (value) => - setState(() => _draft = _draft.copyWith(end: value)), + title: Text( + _draft.eventId == null + ? context.l10n.newEvent + : context.l10n.editEvent, + ), + actions: [ + TextButton( + onPressed: _saving || !_canEdit ? null : _save, + child: Text(context.l10n.save), ), - const SizedBox(height: 12), - TextFormField( - controller: _location, - decoration: InputDecoration( - labelText: context.l10n.location, - prefixIcon: const Icon(Icons.place_outlined), + ], + ), + body: Form( + child: ListView( + padding: const EdgeInsets.all(16), + children: [ + TextFormField( + controller: _title, + enabled: _canEdit, + decoration: InputDecoration(labelText: context.l10n.title), + autofocus: _draft.eventId == null, + onChanged: (v) => + setState(() => _draft = _draft.copyWith(title: v)), ), - onChanged: (v) => _draft = _draft.copyWith(location: v), - ), - const SizedBox(height: 12), - TextFormField( - controller: _description, - minLines: 3, - maxLines: 7, - decoration: InputDecoration(labelText: context.l10n.description), - onChanged: (v) => _draft = _draft.copyWith(description: v), - ), - const SizedBox(height: 12), - DropdownButtonFormField( - initialValue: _frequency, - decoration: InputDecoration(labelText: context.l10n.repeat), - items: [ - DropdownMenuItem( - value: RecurrenceFrequency.none, - child: Text(context.l10n.repeatNone), - ), - DropdownMenuItem( - value: RecurrenceFrequency.daily, - child: Text(context.l10n.repeatDaily), - ), - DropdownMenuItem( - value: RecurrenceFrequency.weekly, - child: Text(context.l10n.repeatWeekly), - ), - DropdownMenuItem( - value: RecurrenceFrequency.monthly, - child: Text(context.l10n.repeatMonthly), - ), - DropdownMenuItem( - value: RecurrenceFrequency.yearly, - child: Text(context.l10n.repeatYearly), - ), - ], - onChanged: (value) => setState(() { - _frequency = value ?? RecurrenceFrequency.none; - _recurrenceChanged = true; - }), - ), - if (_draft.providerRecurringEventId != null) ...[ const SizedBox(height: 12), - ListTile( + DropdownButtonFormField( + initialValue: _source?.id, + decoration: InputDecoration(labelText: context.l10n.calendar), + items: [ + for (final source in widget.sources) + DropdownMenuItem( + value: source.id, + child: Text( + '${source.summary} · ${source.authenticatedAccountEmail ?? source.accountId}', + overflow: TextOverflow.ellipsis, + ), + ), + ], + onChanged: _draft.eventId != null || !_canEdit + ? null + : (id) { + final source = widget.sources.firstWhere( + (s) => s.id == id, + ); + setState( + () => _draft = _draft.copyWith( + accountId: source.accountId, + sourceId: source.id, + providerCalendarId: source.providerCalendarId, + ), + ); + }, + ), + SwitchListTile( contentPadding: EdgeInsets.zero, - leading: const Icon(Icons.call_split), - title: Text(context.l10n.chooseRecurringEventScope), - subtitle: _draft.recurringMutationScope == null + title: Text(context.l10n.allDay), + value: _draft.allDay, + onChanged: !_canEdit ? null - : Text( - _scopeLabel(context, _draft.recurringMutationScope!), + : (value) => setState( + () => _draft = _draft.copyWith(allDay: value), ), - onTap: _selectRecurringScope, ), - ], - const SizedBox(height: 12), - DropdownButtonFormField( - initialValue: _reminderMinutes, - decoration: InputDecoration(labelText: context.l10n.reminder), - items: [ - DropdownMenuItem( - value: 0, - child: Text(context.l10n.repeatNone), + _DateTimeTile( + label: context.l10n.startDateTime, + value: _draft.start!, + allDay: _draft.allDay, + onChanged: !_canEdit + ? null + : (value) => setState( + () => _draft = _draft.copyWith(start: value), + ), + ), + _DateTimeTile( + label: context.l10n.endDateTime, + value: _draft.end!, + allDay: _draft.allDay, + onChanged: !_canEdit + ? null + : (value) => + setState(() => _draft = _draft.copyWith(end: value)), + ), + const SizedBox(height: 12), + TextFormField( + controller: _location, + enabled: _canEdit, + decoration: InputDecoration( + labelText: context.l10n.location, + prefixIcon: const Icon(Icons.place_outlined), ), - for (final value in const [5, 10, 30, 60, 1440]) + onChanged: (v) => _draft = _draft.copyWith(location: v), + ), + const SizedBox(height: 12), + TextFormField( + controller: _description, + enabled: _canEdit, + minLines: 3, + maxLines: 7, + decoration: InputDecoration( + labelText: context.l10n.description, + ), + onChanged: (v) => _draft = _draft.copyWith(description: v), + ), + const SizedBox(height: 12), + DropdownButtonFormField( + initialValue: _frequency, + decoration: InputDecoration(labelText: context.l10n.repeat), + items: [ + DropdownMenuItem( + value: RecurrenceFrequency.none, + child: Text(context.l10n.repeatNone), + ), + DropdownMenuItem( + value: RecurrenceFrequency.daily, + child: Text(context.l10n.repeatDaily), + ), + DropdownMenuItem( + value: RecurrenceFrequency.weekly, + child: Text(context.l10n.repeatWeekly), + ), + DropdownMenuItem( + value: RecurrenceFrequency.monthly, + child: Text(context.l10n.repeatMonthly), + ), DropdownMenuItem( - value: value, - child: Text(context.l10n.reminderMinutesBefore(value)), + value: RecurrenceFrequency.yearly, + child: Text(context.l10n.repeatYearly), ), + ], + onChanged: !_canEdit + ? null + : (value) => setState(() { + _frequency = value ?? RecurrenceFrequency.none; + _recurrenceChanged = true; + }), + ), + if (_draft.providerRecurringEventId != null) ...[ + const SizedBox(height: 12), + ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.call_split), + title: Text(context.l10n.chooseRecurringEventScope), + subtitle: _draft.recurringMutationScope == null + ? null + : Text( + _scopeLabel(context, _draft.recurringMutationScope!), + ), + onTap: _canEdit ? _selectRecurringScope : null, + ), ], - onChanged: (value) => setState(() => _reminderMinutes = value), - ), - const SizedBox(height: 12), - TextFormField( - controller: _guests, - enabled: _draft.canManageAttendees, - decoration: InputDecoration(labelText: context.l10n.guests), - keyboardType: TextInputType.emailAddress, - ), - const SizedBox(height: 12), - TextFormField( - controller: _categories, - decoration: InputDecoration(labelText: context.l10n.categories), - ), - if (_draft.eventId != null) ...[ - const SizedBox(height: 24), - OutlinedButton.icon( - onPressed: _saving ? null : _delete, - icon: const Icon(Icons.delete_outline), - label: Text(context.l10n.deleteEvent), + const SizedBox(height: 12), + DropdownButtonFormField( + initialValue: _reminderMinutes, + decoration: InputDecoration(labelText: context.l10n.reminder), + items: [ + DropdownMenuItem( + value: 0, + child: Text(context.l10n.repeatNone), + ), + for (final value in const [5, 10, 30, 60, 1440]) + DropdownMenuItem( + value: value, + child: Text(context.l10n.reminderMinutesBefore(value)), + ), + ], + onChanged: !_canEdit + ? null + : (value) => setState(() => _reminderMinutes = value), ), + const SizedBox(height: 12), + TextFormField( + controller: _guests, + enabled: _canEdit && _draft.canManageAttendees, + decoration: InputDecoration(labelText: context.l10n.guests), + keyboardType: TextInputType.emailAddress, + ), + const SizedBox(height: 12), + TextFormField( + controller: _categories, + enabled: _canEdit, + decoration: InputDecoration(labelText: context.l10n.categories), + ), + if (_canDelete) ...[ + const SizedBox(height: 24), + OutlinedButton.icon( + onPressed: _saving ? null : _delete, + icon: const Icon(Icons.delete_outline), + label: Text(context.l10n.deleteEvent), + ), + ], + const SizedBox(height: 40), ], - const SizedBox(height: 40), - ], + ), ), ), ); @@ -1071,28 +1670,28 @@ class _AndroidEventEditorState extends ConsumerState { Future _save() async { final start = _draft.start; if (_title.text.trim().isEmpty || start == null) return; + final attendeeEdit = mergeAndroidEventAttendees( + widget.draft.attendees, + _guests.text, + ); + final categories = _categories.text + .split(',') + .map((value) => value.trim()) + .where((value) => value.isNotEmpty) + .toList(); var draft = _draft.copyWith( title: _title.text.trim(), location: _location.text.trim(), description: _description.text.trim(), - attendees: [ - for (final address in _guests.text.split(',')) - if (address.trim().isNotEmpty) - EventAttendeeDraft(email: address.trim()), - ], - attendeesChanged: - _guests.text.trim().isNotEmpty || _draft.attendees.isNotEmpty, - categories: _categories.text - .split(',') - .map((v) => v.trim()) - .where((v) => v.isNotEmpty) - .toList(), - categoriesChanged: true, + attendees: attendeeEdit.attendees, + attendeesChanged: attendeeEdit.changed, + categories: categories, + categoriesChanged: !listEquals(categories, widget.draft.categories), ); if (_reminderMinutes != null) { final value = _reminderMinutes!; draft = draft.copyWith( - reminders: _eventReminders(_source.provider, value), + reminders: _eventReminders(_provider, value), remindersChanged: true, clearReminders: value == 0, ); @@ -1102,7 +1701,7 @@ class _AndroidEventEditorState extends ConsumerState { draft = draft.copyWith( recurrence: rule.repeats ? EventRecurrenceCodec.encode( - _source.provider, + _provider, rule, baseDate: start, allDay: draft.allDay, @@ -1122,12 +1721,29 @@ class _AndroidEventEditorState extends ConsumerState { } setState(() => _saving = true); try { + if ((_reminderMinutes ?? 0) > 0) { + await ref + .read(androidNotificationServiceProvider) + .requestNotificationPermission(); + } if (draft.eventId == null) { await ref.read(calendarRepositoryProvider).createLocalEvent(draft); } else { await ref.read(calendarRepositoryProvider).updateLocalEvent(draft); } - if (mounted) Navigator.pop(context); + ref + .read( + pendingCalendarMutationSyncRequesterForAccountProvider( + draft.accountId, + ), + ) + .request(); + if (mounted) { + await _clearRecovery(); + if (!mounted) return; + setState(() => _allowPop = true); + Navigator.pop(context); + } } on Object catch (error) { if (mounted) { ScaffoldMessenger.of( @@ -1174,7 +1790,19 @@ class _AndroidEventEditorState extends ConsumerState { _draft.eventId!, recurringScope: draft.recurringMutationScope, ); - if (mounted) Navigator.pop(context); + ref + .read( + pendingCalendarMutationSyncRequesterForAccountProvider( + draft.accountId, + ), + ) + .request(); + if (mounted) { + await _clearRecovery(); + if (!mounted) return; + setState(() => _allowPop = true); + Navigator.pop(context); + } } on Object catch (error) { if (mounted) { ScaffoldMessenger.of( @@ -1197,7 +1825,7 @@ class _AndroidEventEditorState extends ConsumerState { ListTile(title: Text(context.l10n.chooseRecurringEventScope)), for (final value in RecurringEventMutationScope.values) if (value != RecurringEventMutationScope.thisAndFuture || - supportsThisAndFollowingEventMutation(_source.provider)) + supportsThisAndFollowingEventMutation(_provider)) ListTile( leading: Icon( _draft.recurringMutationScope == value @@ -1216,6 +1844,181 @@ class _AndroidEventEditorState extends ConsumerState { } return scope; } + + Future _requestClose() async { + if (!_hasPendingEdits || await _confirmDiscardChanges()) { + if (!mounted) return; + await _clearRecovery(); + if (!mounted) return; + setState(() => _allowPop = true); + Navigator.pop(context); + } + } + + Future _confirmDiscardChanges() async => + await showDialog( + context: context, + barrierDismissible: false, + builder: (dialogContext) => AlertDialog( + title: Text(context.l10n.discardChanges), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext, false), + child: Text(context.l10n.cancel), + ), + FilledButton( + onPressed: () => Navigator.pop(dialogContext, true), + child: Text(context.l10n.discardChangesAction), + ), + ], + ), + ) ?? + false; + + Future _restoreRecovery() async { + try { + final raw = await ref.read(secureStorageProvider).read(key: _recoveryKey); + if (raw == null) return; + final value = jsonDecode(raw); + if (value is! Map) return; + final map = value.cast(); + final savedAt = DateTime.tryParse(map['savedAt']?.toString() ?? ''); + if (savedAt == null || + DateTime.now().difference(savedAt).abs() > const Duration(days: 14) || + map['baseTitle'] != widget.draft.title || + map['baseStart'] != widget.draft.start?.toIso8601String()) { + await _clearRecovery(); + return; + } + final start = DateTime.tryParse(map['start']?.toString() ?? ''); + final end = DateTime.tryParse(map['end']?.toString() ?? ''); + final frequency = RecurrenceFrequency.values + .where((value) => value.name == map['frequency']) + .firstOrNull; + if (!mounted) return; + await WidgetsBinding.instance.endOfFrame; + if (!mounted) return; + final recover = await showDialog( + context: context, + barrierDismissible: false, + builder: (dialogContext) => AlertDialog( + title: Text(context.l10n.discardChanges), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext, false), + child: Text(context.l10n.discardChangesAction), + ), + FilledButton( + onPressed: () => Navigator.pop(dialogContext, true), + child: Text(context.l10n.nextcloudRestore), + ), + ], + ), + ); + if (recover != true) { + await _clearRecovery(); + return; + } + if (!mounted) return; + setState(() { + _title.text = map['title']?.toString() ?? _title.text; + _location.text = map['location']?.toString() ?? _location.text; + _description.text = map['description']?.toString() ?? _description.text; + _guests.text = map['guests']?.toString() ?? _guests.text; + _categories.text = map['categories']?.toString() ?? _categories.text; + _reminderMinutes = map['reminderMinutes'] as int?; + if (frequency != null && frequency != _frequency) { + _frequency = frequency; + _recurrenceChanged = true; + } + _draft = _draft.copyWith( + title: _title.text, + location: _location.text, + description: _description.text, + allDay: map['allDay'] as bool? ?? _draft.allDay, + start: start, + end: end, + ); + }); + } on Object { + // A malformed/locked recovery entry must never block the editor. + } finally { + _recoveryLoaded = true; + } + } + + Future _persistRecovery() async { + if (!_recoveryLoaded || !_hasPendingEdits || _allowPop) return; + await ref + .read(secureStorageProvider) + .write( + key: _recoveryKey, + value: jsonEncode({ + 'v': 1, + 'savedAt': DateTime.now().toIso8601String(), + 'baseTitle': widget.draft.title, + 'baseStart': widget.draft.start?.toIso8601String(), + 'title': _title.text, + 'location': _location.text, + 'description': _description.text, + 'guests': _guests.text, + 'categories': _categories.text, + 'allDay': _draft.allDay, + 'start': _draft.start?.toIso8601String(), + 'end': _draft.end?.toIso8601String(), + 'reminderMinutes': _reminderMinutes, + 'frequency': _frequency.name, + }), + ); + } + + Future _clearRecovery() => + ref.read(secureStorageProvider).delete(key: _recoveryKey); +} + +final class AndroidEventAttendeeEdit { + const AndroidEventAttendeeEdit({ + required this.attendees, + required this.changed, + }); + + final List attendees; + final bool changed; +} + +/// Applies the Android email editor without destroying provider-owned guest +/// metadata. Existing matching guests retain names, optional/required state, +/// response status and organizer/self flags. +AndroidEventAttendeeEdit mergeAndroidEventAttendees( + List original, + String editedEmails, +) { + final requested = {}; + for (final value in editedEmails.split(RegExp(r'[,;\n]'))) { + final email = value.trim(); + if (email.isNotEmpty) { + requested.putIfAbsent(email.toLowerCase(), () => email); + } + } + final result = []; + final matched = {}; + for (final attendee in original) { + if (attendee.self || attendee.organizer) { + result.add(attendee); + continue; + } + final key = attendee.email.trim().toLowerCase(); + if (requested.containsKey(key) && matched.add(key)) result.add(attendee); + } + for (final entry in requested.entries) { + if (matched.add(entry.key)) { + result.add(EventAttendeeDraft(email: entry.value)); + } + } + return AndroidEventAttendeeEdit( + attendees: listEquals(result, original) ? original : result, + changed: !listEquals(result, original), + ); } String _scopeLabel(BuildContext context, RecurringEventMutationScope scope) => @@ -1262,7 +2065,7 @@ class _DateTimeTile extends StatelessWidget { final String label; final DateTime value; final bool allDay; - final ValueChanged onChanged; + final ValueChanged? onChanged; @override Widget build(BuildContext context) => ListTile( contentPadding: EdgeInsets.zero, @@ -1277,25 +2080,34 @@ class _DateTimeTile extends StatelessWidget { DateFormat.yMMMd().format(value), ), ), - onTap: () async { - final date = await showDatePicker( - context: context, - initialDate: value, - firstDate: DateTime(1970), - lastDate: DateTime(2200), - ); - if (date == null || !context.mounted) return; - if (allDay) return onChanged(date); - final time = await showTimePicker( - context: context, - initialTime: TimeOfDay.fromDateTime(value), - ); - if (time != null) { - onChanged( - DateTime(date.year, date.month, date.day, time.hour, time.minute), - ); - } - }, + enabled: onChanged != null, + onTap: onChanged == null + ? null + : () async { + final date = await showDatePicker( + context: context, + initialDate: value, + firstDate: DateTime(1970), + lastDate: DateTime(2200), + ); + if (date == null || !context.mounted) return; + if (allDay) return onChanged!(date); + final time = await showTimePicker( + context: context, + initialTime: TimeOfDay.fromDateTime(value), + ); + if (time != null) { + onChanged!( + DateTime( + date.year, + date.month, + date.day, + time.hour, + time.minute, + ), + ); + } + }, ); } @@ -1366,11 +2178,15 @@ String _periodLabel( ScheduleViewMode.day || ScheduleViewMode.agenda => DateFormat.yMMMMd().format(date), ScheduleViewMode.week => - '${DateFormat.MMMd().format(ScheduleRange.week(date).start)} – ${DateFormat.MMMd().format(ScheduleRange.week(date).end.subtract(const Duration(days: 1)))}', + '${DateFormat.MMMd().format(ScheduleRange.week(date).start)} – ' + '${DateFormat.MMMd().format(_previousCivilDay(ScheduleRange.week(date).end))}', ScheduleViewMode.month => DateFormat.yMMMM().format(date), ScheduleViewMode.year => '${date.year}', }; +DateTime _previousCivilDay(DateTime value) => + DateTime(value.year, value.month, value.day - 1); + String _modeLabel(BuildContext context, ScheduleViewMode mode) => switch (mode) { ScheduleViewMode.day => context.l10n.viewDay, @@ -1382,6 +2198,12 @@ String _modeLabel(BuildContext context, ScheduleViewMode mode) => bool _sameDay(DateTime a, DateTime b) => a.year == b.year && a.month == b.month && a.day == b.day; + +int _firstWeekday(BuildContext context) { + final index = MaterialLocalizations.of(context).firstDayOfWeekIndex; + return index == 0 ? DateTime.sunday : index; +} + Object? _eventReminders(BusyProvider provider, int minutes) { if (minutes <= 0) return null; return provider == BusyProvider.microsoft diff --git a/lib/src/android/presentation/android_settings_screen.dart b/lib/src/android/presentation/android_settings_screen.dart index de715933..6f3c881e 100644 --- a/lib/src/android/presentation/android_settings_screen.dart +++ b/lib/src/android/presentation/android_settings_screen.dart @@ -1,9 +1,10 @@ import 'dart:async'; import 'dart:convert'; -import 'dart:typed_data'; +import 'dart:io'; import 'package:busymax_android_platform/busymax_android_platform.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:intl/intl.dart'; import 'package:package_info_plus/package_info_plus.dart'; @@ -13,6 +14,8 @@ import '../../calendar_providers/calendar_colors.dart'; import '../../core/auth/oauth_models.dart'; import '../../dav/dav_errors.dart'; import '../../dav/mutation/dav_conflict_repository.dart'; +import '../../dav/nextcloud/nextcloud_sharing_service.dart'; +import '../../dav/nextcloud/nextcloud_trash_service.dart'; import '../../features/accounts/data/accounts_repository.dart'; import '../../features/accounts/domain/account_collection_creation_capabilities.dart'; import '../../features/calendar/data/calendar_repository.dart'; @@ -182,6 +185,20 @@ class _AndroidSettingsScreenState extends ConsumerState { ), onTap: () => unawaited(_editTaskList(list, accounts)), ), + for (final account in accounts.where( + (account) => account.provider == BusyProvider.nextcloud, + )) + ListTile( + leading: const Icon(Icons.delete_sweep_outlined), + title: Text(context.l10n.nextcloudTrash), + subtitle: Text(account.displayLabel), + onTap: () => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => + AndroidNextcloudTrashScreen(accountId: account.id), + ), + ), + ), ], ), _Section( @@ -391,6 +408,11 @@ class _AndroidSettingsScreenState extends ConsumerState { value ? NotificationDetailLevel.private : NotificationDetailLevel.normal, + ) + .then( + (_) => ref + .read(notificationReconcilerProvider) + .reconcile(), ), ), ), @@ -551,6 +573,7 @@ class _AndroidSettingsScreenState extends ConsumerState { AccountEntity? reconnecting, ]) async { if (_connecting != null) return; + final localNetworkDenied = context.l10n.nextcloudOperationDenied; String? email; String? password; String? server; @@ -565,10 +588,20 @@ class _AndroidSettingsScreenState extends ConsumerState { initialValue: reconnecting?.authority, helper: context.l10n.nextcloudBrowserAuthorizationHelp, ); - if (server == null) return; + if (server == null || !mounted) return; final uri = Uri.tryParse(server); - if (uri != null && _isLocalHost(uri.host)) { - await BusyMaxAndroidPlatform.instance.requestLocalNetworkAccess(); + if (uri != null && isAndroidLocalNetworkHost(uri.host)) { + try { + final granted = await BusyMaxAndroidPlatform.instance + .requestLocalNetworkAccess(); + if (!granted) { + _message(localNetworkDenied); + return; + } + } on PlatformException catch (error) { + _message(error.message ?? localNetworkDenied); + return; + } } } setState(() => _connecting = provider); @@ -786,6 +819,13 @@ class _AndroidSettingsScreenState extends ConsumerState { title: Text(context.l10n.reminders), onTap: () => Navigator.pop(context, 'reminders'), ), + if (source.provider == BusyProvider.nextcloud && + source.davCollectionId != null) + ListTile( + leading: const Icon(Icons.people_outline), + title: Text(context.l10n.nextcloudSharing), + onTap: () => Navigator.pop(context, 'sharing'), + ), if (source.provider == BusyProvider.nextcloud && source.davCollectionId != null) ListTile( @@ -835,6 +875,17 @@ class _AndroidSettingsScreenState extends ConsumerState { .setSourceColor(source.id, choice); } else if (action == 'export') { await _exportCollection(source.accountId, source.davCollectionId!); + } else if (action == 'sharing') { + if (!mounted) return; + await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => AndroidNextcloudSharingScreen( + accountId: source.accountId, + collectionId: source.davCollectionId!, + title: source.summary, + ), + ), + ); } else if (action == 'delete') { final remove = source.capabilities.removalMode == @@ -901,6 +952,13 @@ class _AndroidSettingsScreenState extends ConsumerState { title: Text(context.l10n.reminders), onTap: () => Navigator.pop(context, 'reminders'), ), + if (list.davCollectionId != null && + capabilities.supportsNativeExport) + ListTile( + leading: const Icon(Icons.people_outline), + title: Text(context.l10n.nextcloudSharing), + onTap: () => Navigator.pop(context, 'sharing'), + ), if (list.davCollectionId != null && capabilities.supportsNativeExport) ListTile( @@ -944,6 +1002,17 @@ class _AndroidSettingsScreenState extends ConsumerState { .setRemindersEnabled(list.id, reminders); } else if (action == 'export') { await _exportCollection(list.accountId, list.davCollectionId!); + } else if (action == 'sharing') { + if (!mounted) return; + await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => AndroidNextcloudSharingScreen( + accountId: list.accountId, + collectionId: list.davCollectionId!, + title: list.title, + ), + ), + ); } else if (action == 'delete') { final confirmed = await _confirm( l10n.deleteList, @@ -1344,16 +1413,6 @@ class _AndroidSettingsScreenState extends ConsumerState { ) ?? false; - bool _isLocalHost(String host) { - final value = host.toLowerCase(); - if (value == 'localhost' || value.endsWith('.local')) return true; - final parts = value.split('.').map(int.tryParse).toList(); - if (parts.length != 4 || parts.any((part) => part == null)) return false; - return parts[0] == 10 || - (parts[0] == 192 && parts[1] == 168) || - (parts[0] == 172 && parts[1]! >= 16 && parts[1]! <= 31); - } - void _message(String message) { if (!mounted) return; ScaffoldMessenger.of( @@ -1362,6 +1421,413 @@ class _AndroidSettingsScreenState extends ConsumerState { } } +bool isAndroidLocalNetworkHost(String host) { + var value = host.trim().toLowerCase(); + if (value.startsWith('[') && value.endsWith(']')) { + value = value.substring(1, value.length - 1); + } + value = value.split('%').first; + if (value.isEmpty) return false; + if (value == 'localhost' || + value.endsWith('.local') || + value.endsWith('.lan') || + value.endsWith('.home') || + value.endsWith('.internal')) { + return true; + } + final address = InternetAddress.tryParse(value); + if (address == null) return !value.contains('.'); + final bytes = address.rawAddress; + if (address.type == InternetAddressType.IPv4) { + return bytes[0] == 10 || + bytes[0] == 127 || + (bytes[0] == 169 && bytes[1] == 254) || + (bytes[0] == 192 && bytes[1] == 168) || + (bytes[0] == 172 && bytes[1] >= 16 && bytes[1] <= 31); + } + final loopback = bytes.take(15).every((byte) => byte == 0) && bytes[15] == 1; + final uniqueLocal = (bytes[0] & 0xfe) == 0xfc; + final linkLocal = bytes[0] == 0xfe && (bytes[1] & 0xc0) == 0x80; + return loopback || uniqueLocal || linkLocal; +} + +class AndroidNextcloudSharingScreen extends ConsumerStatefulWidget { + const AndroidNextcloudSharingScreen({ + super.key, + required this.accountId, + required this.collectionId, + required this.title, + }); + + final String accountId; + final String collectionId; + final String title; + + @override + ConsumerState createState() => + _AndroidNextcloudSharingScreenState(); +} + +class _AndroidNextcloudSharingScreenState + extends ConsumerState { + final _search = TextEditingController(); + NextcloudSharingState? _state; + List _recipients = const []; + bool _busy = true; + Object? _error; + + NextcloudSharingService get _service => + ref.read(nextcloudSharingServiceProvider(widget.accountId)); + + @override + void initState() { + super.initState(); + unawaited(_load()); + } + + @override + void dispose() { + _search.dispose(); + super.dispose(); + } + + Future _load() async { + setState(() { + _busy = true; + _error = null; + }); + try { + final state = await _service.load(widget.collectionId); + if (mounted) setState(() => _state = state); + } on Object catch (error) { + if (mounted) setState(() => _error = error); + } finally { + if (mounted) setState(() => _busy = false); + } + } + + Future _findRecipients() async { + if (_search.text.trim().isEmpty) return; + setState(() { + _busy = true; + _error = null; + }); + try { + final recipients = await _service.search(_search.text); + if (mounted) setState(() => _recipients = recipients); + } on Object catch (error) { + if (mounted) setState(() => _error = error); + } finally { + if (mounted) setState(() => _busy = false); + } + } + + Future _changeShare( + NextcloudShareRecipient recipient, + bool? writable, + ) async { + setState(() => _busy = true); + try { + await _service.changeShare( + widget.collectionId, + recipient, + writable: writable, + ); + _search.clear(); + _recipients = const []; + await _load(); + } on Object catch (error) { + if (mounted) setState(() => _error = error); + } finally { + if (mounted) setState(() => _busy = false); + } + } + + Future _setPublished(bool value) async { + if (value) { + final confirmed = await showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: Text(context.l10n.nextcloudPublish), + content: Text(context.l10n.nextcloudPublishWarning), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext, false), + child: Text(context.l10n.cancel), + ), + FilledButton( + onPressed: () => Navigator.pop(dialogContext, true), + child: Text(context.l10n.nextcloudPublish), + ), + ], + ), + ); + if (confirmed != true) return; + } + setState(() => _busy = true); + try { + await _service.setPublished(widget.collectionId, value); + await _load(); + } on Object catch (error) { + if (mounted) setState(() => _error = error); + } finally { + if (mounted) setState(() => _busy = false); + } + } + + @override + Widget build(BuildContext context) { + final state = _state; + return Scaffold( + appBar: AppBar(title: Text(widget.title)), + body: _busy && state == null + ? const Center(child: CircularProgressIndicator()) + : RefreshIndicator( + onRefresh: _load, + child: ListView( + padding: const EdgeInsets.all(16), + children: [ + if (_error != null) + ListTile( + leading: const Icon(Icons.error_outline), + title: Text(context.l10n.operationFailed), + subtitle: Text('$_error'), + ), + if (state?.canPublish ?? false) + SwitchListTile( + contentPadding: EdgeInsets.zero, + secondary: const Icon(Icons.public), + title: Text( + state!.publishUrl == null + ? context.l10n.nextcloudPublish + : context.l10n.nextcloudUnpublish, + ), + subtitle: state.publishUrl == null + ? null + : SelectableText(state.publishUrl.toString()), + value: state.publishUrl != null, + onChanged: _busy ? null : _setPublished, + ), + if (state?.canShare ?? false) ...[ + TextField( + controller: _search, + decoration: InputDecoration( + labelText: context.l10n.nextcloudRecipientSearch, + suffixIcon: IconButton( + onPressed: _busy ? null : _findRecipients, + icon: const Icon(Icons.search), + ), + ), + onSubmitted: (_) => unawaited(_findRecipients()), + ), + for (final recipient in _recipients) + ListTile( + leading: Icon( + recipient.group ? Icons.groups : Icons.person_outline, + ), + title: Text(recipient.label), + trailing: PopupMenuButton( + onSelected: (value) => + unawaited(_changeShare(recipient, value)), + itemBuilder: (context) => [ + PopupMenuItem( + value: false, + child: Text(context.l10n.nextcloudReadAccess), + ), + PopupMenuItem( + value: true, + child: Text(context.l10n.nextcloudWriteAccess), + ), + ], + ), + ), + ], + const Divider(height: 32), + for (final share + in state?.shares ?? const []) + ListTile( + leading: Icon( + share.recipient.group + ? Icons.groups + : Icons.person_outline, + ), + title: Text(share.recipient.label), + subtitle: Text( + share.writable + ? context.l10n.nextcloudWriteAccess + : context.l10n.nextcloudReadAccess, + ), + trailing: PopupMenuButton( + onSelected: (value) => unawaited( + _changeShare(share.recipient, switch (value) { + 'read' => false, + 'write' => true, + _ => null, + }), + ), + itemBuilder: (context) => [ + PopupMenuItem( + value: 'read', + child: Text(context.l10n.nextcloudReadAccess), + ), + PopupMenuItem( + value: 'write', + child: Text(context.l10n.nextcloudWriteAccess), + ), + PopupMenuItem( + value: 'remove', + child: Text(context.l10n.nextcloudRevokeShare), + ), + ], + ), + ), + if (state != null && + state.shares.isEmpty && + _recipients.isEmpty) + ListTile(title: Text(context.l10n.nextcloudNoRecipients)), + if (_busy) + const Padding( + padding: EdgeInsets.all(16), + child: LinearProgressIndicator(), + ), + ], + ), + ), + ); + } +} + +class AndroidNextcloudTrashScreen extends ConsumerStatefulWidget { + const AndroidNextcloudTrashScreen({super.key, required this.accountId}); + + final String accountId; + + @override + ConsumerState createState() => + _AndroidNextcloudTrashScreenState(); +} + +class _AndroidNextcloudTrashScreenState + extends ConsumerState { + NextcloudTrashListing? _listing; + bool _busy = true; + Object? _error; + + NextcloudTrashService get _service => + ref.read(nextcloudTrashServiceProvider(widget.accountId)); + + @override + void initState() { + super.initState(); + unawaited(_load()); + } + + Future _load() async { + setState(() { + _busy = true; + _error = null; + }); + try { + final listing = await _service.list(); + if (mounted) setState(() => _listing = listing); + } on Object catch (error) { + if (mounted) setState(() => _error = error); + } finally { + if (mounted) setState(() => _busy = false); + } + } + + Future _act(NextcloudTrashItem item, {required bool permanent}) async { + if (permanent) { + final confirmed = await showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: Text(context.l10n.nextcloudPermanentDelete), + content: Text(context.l10n.nextcloudPermanentDeleteWarning), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext, false), + child: Text(context.l10n.cancel), + ), + FilledButton( + onPressed: () => Navigator.pop(dialogContext, true), + child: Text(context.l10n.nextcloudPermanentDelete), + ), + ], + ), + ); + if (confirmed != true) return; + } + setState(() => _busy = true); + try { + if (permanent) { + await _service.permanentlyDelete(item); + } else { + await _service.restore(item); + } + await _load(); + } on Object catch (error) { + if (mounted) setState(() => _error = error); + } finally { + if (mounted) setState(() => _busy = false); + } + } + + @override + Widget build(BuildContext context) { + final items = _listing?.items ?? const []; + return Scaffold( + appBar: AppBar(title: Text(context.l10n.nextcloudTrash)), + body: RefreshIndicator( + onRefresh: _load, + child: ListView( + padding: const EdgeInsets.all(16), + children: [ + if (_error != null) + ListTile( + leading: const Icon(Icons.error_outline), + title: Text(context.l10n.operationFailed), + subtitle: Text('$_error'), + ), + if (!_busy && items.isEmpty && _error == null) + ListTile(title: Text(context.l10n.nextcloudTrashEmpty)), + for (final item in items) + ListTile( + leading: Icon(switch (item.kind) { + NextcloudTrashKind.event => Icons.event_outlined, + NextcloudTrashKind.task => Icons.task_alt_outlined, + NextcloudTrashKind.calendar => Icons.calendar_month_outlined, + NextcloudTrashKind.taskList => Icons.checklist_outlined, + NextcloudTrashKind.mixedCollection => + Icons.folder_copy_outlined, + }), + title: Text(item.title), + subtitle: item.deletedAt == null ? null : Text(item.deletedAt!), + trailing: PopupMenuButton( + onSelected: (value) => + unawaited(_act(item, permanent: value == 'delete')), + itemBuilder: (context) => [ + if (item.canRestore) + PopupMenuItem( + value: 'restore', + child: Text(context.l10n.nextcloudRestore), + ), + if (item.canPermanentlyDelete) + PopupMenuItem( + value: 'delete', + child: Text(context.l10n.nextcloudPermanentDelete), + ), + ], + ), + ), + if (_busy) const LinearProgressIndicator(), + ], + ), + ), + ); + } +} + class _Section extends StatelessWidget { const _Section({required this.title, required this.children}); final String title; diff --git a/lib/src/android/presentation/android_tasks_screen.dart b/lib/src/android/presentation/android_tasks_screen.dart index 22acaced..bface4cd 100644 --- a/lib/src/android/presentation/android_tasks_screen.dart +++ b/lib/src/android/presentation/android_tasks_screen.dart @@ -1,12 +1,15 @@ +import 'dart:async'; import 'dart:convert'; - +import 'package:busymax_android_platform/busymax_android_platform.dart'; import 'package:drift/drift.dart' hide Column; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:intl/intl.dart'; import '../../app/app_bootstrap.dart'; import '../../features/accounts/data/accounts_repository.dart'; +import '../../dav/ical/ical_task_alarm.dart'; import '../../features/task_lists/data/task_lists_repository.dart'; import '../../features/tasks/data/tasks_repository.dart'; import '../../features/tasks/domain/task_capabilities.dart'; @@ -17,6 +20,7 @@ import '../../l10n/l10n.dart'; import '../../providers/busy_provider.dart'; import '../../schedule/schedule_filters.dart'; import '../../schedule/schedule_item.dart'; +import '../android_notifications.dart'; final _androidTasksProvider = FutureProvider.autoDispose .family< @@ -74,6 +78,15 @@ class _AndroidTasksScreenState extends ConsumerState { @override Widget build(BuildContext context) { final lists = ref.watch(scheduleTaskListsProvider).valueOrNull ?? const []; + final accounts = + ref.watch(accountsStreamProvider).valueOrNull ?? + const []; + final accountLabels = { + for (final account in accounts) account.id: account.displayLabel, + }; + final selectedAccount = accounts + .where((account) => account.id == _accountId) + .firstOrNull; if (_listId != null && !lists.any( (list) => list.id == _listId && list.accountId == _accountId, @@ -96,10 +109,16 @@ class _AndroidTasksScreenState extends ConsumerState { actions: [ PopupMenuButton( icon: const Icon(Icons.filter_list), - onSelected: (value) => setState(() { - if (value == 'completed') _showCompleted = !_showCompleted; - if (value == 'no-date') _showNoDate = !_showNoDate; - }), + onSelected: (value) { + if (value == 'clear-completed') { + unawaited(_clearCompleted()); + return; + } + setState(() { + if (value == 'completed') _showCompleted = !_showCompleted; + if (value == 'no-date') _showNoDate = !_showNoDate; + }); + }, itemBuilder: (context) => [ CheckedPopupMenuItem( value: 'completed', @@ -111,6 +130,13 @@ class _AndroidTasksScreenState extends ConsumerState { checked: _showNoDate, child: Text(context.l10n.noDate), ), + if (_listId != null && + (selectedAccount?.provider == BusyProvider.google || + selectedAccount?.provider == BusyProvider.nextcloud)) + PopupMenuItem( + value: 'clear-completed', + child: Text(context.l10n.clearCompleted), + ), ], ), ], @@ -143,7 +169,10 @@ class _AndroidTasksScreenState extends ConsumerState { for (final list in lists) DropdownMenuItem( value: _taskListKey(list.accountId, list.id), - child: Text(list.title), + child: Text( + '${list.title} · ${accountLabels[list.accountId] ?? list.accountId}', + overflow: TextOverflow.ellipsis, + ), ), ], onChanged: (value) => setState(() { @@ -218,6 +247,7 @@ class _AndroidTasksScreenState extends ConsumerState { ), ), floatingActionButton: FloatingActionButton( + heroTag: 'android-tasks-add', onPressed: lists.isEmpty ? null : () => _create(lists), tooltip: context.l10n.newTask, child: const Icon(Icons.add_task), @@ -245,6 +275,23 @@ class _AndroidTasksScreenState extends ConsumerState { } } + Future _clearCompleted() async { + final accountId = _accountId; + final listId = _listId; + if (accountId == null || listId == null) return; + try { + await ref + .read(tasksRepositoryForAccountProvider(accountId)) + .clearCompleted(listId); + } on Object catch (error) { + if (mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('$error'))); + } + } + } + Future _create(List lists) async { final current = lists .where((list) => list.id == _listId && list.accountId == _accountId) @@ -255,6 +302,12 @@ class _AndroidTasksScreenState extends ConsumerState { context, lists, title: context.l10n.newTask, + accountLabels: { + for (final account + in ref.read(accountsStreamProvider).valueOrNull ?? + const []) + account.id: account.displayLabel, + }, ); if (selected == null || !mounted) return; final accounts = @@ -276,6 +329,7 @@ Future selectAndroidTaskList( BuildContext context, List lists, { required String title, + Map accountLabels = const {}, }) { if (lists.length == 1) return Future.value(lists.single); return showModalBottomSheet( @@ -290,6 +344,7 @@ Future selectAndroidTaskList( ListTile( leading: const Icon(Icons.checklist_outlined), title: Text(list.title), + subtitle: Text(accountLabels[list.accountId] ?? list.accountId), onTap: () => Navigator.pop(sheetContext, list), ), ], @@ -307,13 +362,24 @@ Future showAndroidTaskEditor( DateTime? initialDue, }) async { TaskEntity? entity; + TaskListEntity? selectedList = creationList; if (task != null) { entity = await ref .read(tasksRepositoryForAccountProvider(task.accountId)) .watchTask(task.sourceId, task.id) .first; if (!context.mounted || entity == null) return; + selectedList = + (await ref + .read(taskListsRepositoryForAccountProvider(task.accountId)) + .listTaskLists()) + .where((list) => list.id == task.sourceId) + .firstOrNull; } + final account = await ref + .read(accountsRepositoryProvider) + .accountById(task?.accountId ?? creationList!.accountId); + if (!context.mounted) return; await Navigator.of(context).push( MaterialPageRoute( fullscreenDialog: true, @@ -323,6 +389,8 @@ Future showAndroidTaskEditor( task: entity, creationList: creationList, initialDue: initialDue, + accountLabel: account?.displayLabel, + listLabel: selectedList?.title, ), ), ); @@ -336,12 +404,16 @@ class AndroidTaskEditor extends ConsumerStatefulWidget { this.task, this.creationList, this.initialDue, + this.accountLabel, + this.listLabel, }); final String accountId; final BusyProvider provider; final TaskEntity? task; final TaskListEntity? creationList; final DateTime? initialDue; + final String? accountLabel; + final String? listLabel; @override ConsumerState createState() => _AndroidTaskEditorState(); } @@ -367,10 +439,17 @@ class _AndroidTaskEditorState extends ConsumerState { late final TextEditingController _location = TextEditingController( text: _draft.location, ); + late final TextEditingController _url = TextEditingController( + text: _draft.taskUrl, + ); late final TextEditingController _categories = TextEditingController( text: _draft.categories.join(', '), ); bool _saving = false; + bool _allowPop = false; + bool _recoveryLoaded = false; + Timer? _recoveryTimer; + late final TaskDetailsDraft _initialDraft = _draft; late RecurrenceFrequency _recurrenceFrequency = _taskRecurrence().frequency; TaskCollectionCapabilities get _capabilities => switch (widget.provider) { @@ -389,11 +468,36 @@ class _AndroidTaskEditorState extends ConsumerState { BusyProvider.webCal => noTaskCollectionCapabilities, }; + bool get _canWrite => widget.task == null + ? _capabilities.canCreateTasks + : _capabilities.canUpdateTasks; + + bool get _hasPendingEdits => !_draft.hasSameValues(_initialDraft); + + String get _recoveryKey => + 'busymax.android.task-draft.${widget.accountId}.' + '${widget.task?.id ?? 'new-${_initialDraft.taskListId}'}'; + + @override + void initState() { + super.initState(); + unawaited(_restoreRecovery()); + _recoveryTimer = Timer.periodic( + const Duration(seconds: 1), + (_) => unawaited(_persistRecovery()), + ); + } + @override void dispose() { + _recoveryTimer?.cancel(); + if (_recoveryLoaded && _hasPendingEdits && !_allowPop) { + unawaited(_persistRecovery()); + } _title.dispose(); _notes.dispose(); _location.dispose(); + _url.dispose(); _categories.dispose(); super.dispose(); } @@ -410,178 +514,506 @@ class _AndroidTaskEditorState extends ConsumerState { ); } final capabilities = _capabilities; - return Scaffold( - appBar: AppBar( - leading: const CloseButton(), - title: Text( - widget.task == null ? context.l10n.newTask : context.l10n.editTask, - ), - actions: [ - TextButton( - onPressed: _saving ? null : _save, - child: Text(context.l10n.save), - ), - ], - ), - body: ListView( - padding: const EdgeInsets.all(16), - children: [ - TextField( - controller: _title, - autofocus: widget.task == null, - decoration: InputDecoration(labelText: context.l10n.title), - onChanged: (v) => _draft = _draft.copyWith(title: v), + final canWrite = _canWrite; + return PopScope( + canPop: _allowPop || !_hasPendingEdits, + onPopInvokedWithResult: (didPop, _) async { + if (didPop || _saving || _allowPop) return; + if (await _confirmDiscardChanges() && mounted) { + await _clearRecovery(); + if (!mounted) return; + setState(() => _allowPop = true); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) Navigator.pop(context); + }); + } + }, + child: Scaffold( + appBar: AppBar( + leading: IconButton( + tooltip: MaterialLocalizations.of(context).closeButtonTooltip, + onPressed: _saving ? null : _requestClose, + icon: const Icon(Icons.close), ), - const SizedBox(height: 12), - TextField( - controller: _notes, - minLines: 3, - maxLines: 8, - decoration: InputDecoration(labelText: context.l10n.notes), - onChanged: (v) => _draft = _draft.copyWith(notes: v), + title: Text( + widget.task == null ? context.l10n.newTask : context.l10n.editTask, ), - if (capabilities.supportsDueDate) - ListTile( - contentPadding: EdgeInsets.zero, - leading: const Icon(Icons.event), - title: Text(context.l10n.dueDate), - subtitle: Text(_draft.dueDate ?? context.l10n.noDate), - trailing: _draft.dueDate == null - ? null - : IconButton( - icon: const Icon(Icons.clear), - onPressed: () => setState( - () => _draft = _draft.copyWith( - dueDate: null, - microsoftDueTime: null, - ), - ), - ), - onTap: _pickDue, + actions: [ + TextButton( + onPressed: _saving || !canWrite ? null : _save, + child: Text(context.l10n.save), ), - if (capabilities.supportsDueTime && _draft.dueDate != null) - ListTile( - contentPadding: EdgeInsets.zero, - leading: const Icon(Icons.schedule), - title: Text(context.l10n.endTime), - subtitle: Text(_draft.microsoftDueTime ?? context.l10n.allDay), - onTap: _pickDueTime, + ], + ), + body: ListView( + padding: const EdgeInsets.all(16), + children: [ + Card( + child: ListTile( + leading: const Icon(Icons.account_circle_outlined), + title: Text(widget.listLabel ?? _draft.taskListId), + subtitle: Text(widget.accountLabel ?? widget.accountId), + ), ), - if (capabilities.supportsReminderDateTime) - SwitchListTile( - contentPadding: EdgeInsets.zero, - title: Text(context.l10n.reminder), - value: _draft.microsoftReminderEnabled, - onChanged: (value) => setState( - () => _draft = _draft.copyWith( - microsoftReminderEnabled: value, - microsoftReminderDate: value - ? (_draft.dueDate ?? _today()) - : null, - microsoftReminderTime: value - ? (_draft.microsoftDueTime ?? '09:00') - : null, + const SizedBox(height: 12), + TextField( + controller: _title, + enabled: canWrite, + autofocus: widget.task == null, + decoration: InputDecoration(labelText: context.l10n.title), + onChanged: (v) => _draft = _draft.copyWith(title: v), + ), + const SizedBox(height: 12), + TextField( + controller: _notes, + enabled: canWrite, + minLines: 3, + maxLines: 8, + decoration: InputDecoration(labelText: context.l10n.notes), + onChanged: (v) => _draft = _draft.copyWith(notes: v), + ), + if (capabilities.supportsDueDate) + ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.event), + title: Text(context.l10n.dueDate), + subtitle: Text(_draft.dueDate ?? context.l10n.noDate), + trailing: _draft.dueDate == null + ? null + : IconButton( + icon: const Icon(Icons.clear), + onPressed: !canWrite + ? null + : () => setState( + () => _draft = _draft.copyWith( + dueDate: null, + microsoftDueTime: null, + ), + ), + ), + enabled: canWrite, + onTap: canWrite ? _pickDue : null, + ), + if (capabilities.supportsDueTime && _draft.dueDate != null) + ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.schedule), + title: Text(context.l10n.endTime), + subtitle: Text(_draft.microsoftDueTime ?? context.l10n.allDay), + enabled: canWrite, + onTap: canWrite ? _pickDueTime : null, + ), + if (capabilities.supportsStartDateTime) + ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.play_arrow_outlined), + title: Text(context.l10n.startDate), + subtitle: Text( + _draft.microsoftStartDate ?? context.l10n.noDate, ), + trailing: _draft.microsoftStartDate == null + ? null + : IconButton( + icon: const Icon(Icons.clear), + onPressed: !canWrite + ? null + : () => setState( + () => _draft = _draft.copyWith( + microsoftStartDate: null, + microsoftStartTime: null, + ), + ), + ), + enabled: canWrite, + onTap: canWrite ? _pickStartDate : null, ), - ), - if (capabilities.supportsImportance) - DropdownButtonFormField( - initialValue: _draft.importance, - decoration: InputDecoration(labelText: context.l10n.importance), - items: [ - DropdownMenuItem( - value: 'low', - child: Text(context.l10n.importanceLow), + if (capabilities.supportsStartDateTime && + _draft.microsoftStartDate != null) + ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.schedule), + title: Text(context.l10n.startTime), + subtitle: Text( + _draft.microsoftStartTime ?? context.l10n.allDay, ), - DropdownMenuItem( - value: 'normal', - child: Text(context.l10n.importanceNormal), + enabled: canWrite, + onTap: canWrite ? _pickStartTime : null, + ), + if (capabilities.supportsReminderDateTime && + !capabilities.supportsMultipleReminders) + SwitchListTile( + contentPadding: EdgeInsets.zero, + title: Text(context.l10n.reminder), + value: _draft.microsoftReminderEnabled, + onChanged: !canWrite + ? null + : (value) => setState( + () => _draft = _draft.copyWith( + microsoftReminderEnabled: value, + microsoftReminderDate: value + ? (_draft.microsoftReminderDate ?? _today()) + : null, + microsoftReminderTime: value + ? (_draft.microsoftReminderTime ?? '09:00') + : null, + ), + ), + ), + if (capabilities.supportsReminderDateTime && + !capabilities.supportsMultipleReminders && + _draft.microsoftReminderEnabled) ...[ + ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.event_outlined), + title: Text(context.l10n.reminderDate), + subtitle: Text( + _draft.microsoftReminderDate ?? context.l10n.noDate, ), - DropdownMenuItem( - value: 'high', - child: Text(context.l10n.importanceHigh), + enabled: canWrite, + onTap: canWrite ? _pickReminderDate : null, + ), + ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.alarm_outlined), + title: Text(context.l10n.reminderTime), + subtitle: Text( + _draft.microsoftReminderTime ?? context.l10n.noDate, ), - ], - onChanged: (v) => - setState(() => _draft = _draft.copyWith(importance: v)), - ), - if (capabilities.supportsRecurrence) ...[ - const SizedBox(height: 12), - DropdownButtonFormField( - initialValue: _recurrenceFrequency, - decoration: InputDecoration(labelText: context.l10n.repeat), - items: [ - DropdownMenuItem( - value: RecurrenceFrequency.none, - child: Text(context.l10n.repeatNone), + enabled: canWrite, + onTap: canWrite ? _pickReminderTime : null, + ), + ], + if (capabilities.supportsImportance) + DropdownButtonFormField( + initialValue: _draft.importance, + decoration: InputDecoration(labelText: context.l10n.importance), + items: [ + DropdownMenuItem( + value: 'low', + child: Text(context.l10n.importanceLow), + ), + DropdownMenuItem( + value: 'normal', + child: Text(context.l10n.importanceNormal), + ), + DropdownMenuItem( + value: 'high', + child: Text(context.l10n.importanceHigh), + ), + ], + onChanged: !canWrite + ? null + : (v) => setState( + () => _draft = _draft.copyWith(importance: v), + ), + ), + if (capabilities.supportsIcalPriority) + DropdownButtonFormField( + initialValue: _draft.icalPriority, + decoration: InputDecoration(labelText: context.l10n.priority), + items: [ + for (var value = 0; value <= 9; value++) + DropdownMenuItem( + value: value, + child: Text(value == 0 ? '0 · —' : '$value'), + ), + ], + onChanged: !canWrite + ? null + : (value) => setState( + () => _draft = _draft.copyWith(icalPriority: value), + ), + ), + if (capabilities.supportsTaskStatus) + DropdownButtonFormField( + initialValue: _draft.taskStatus, + decoration: InputDecoration(labelText: context.l10n.taskStatus), + items: [ + DropdownMenuItem( + value: null, + child: Text(context.l10n.repeatNone), + ), + DropdownMenuItem( + value: 'NEEDS-ACTION', + child: Text(context.l10n.taskStatusNeedsAction), + ), + DropdownMenuItem( + value: 'IN-PROCESS', + child: Text(context.l10n.taskStatusInProcess), + ), + DropdownMenuItem( + value: 'COMPLETED', + child: Text(context.l10n.taskStatusCompleted), + ), + DropdownMenuItem( + value: 'CANCELLED', + child: Text(context.l10n.taskStatusCancelled), + ), + ], + onChanged: !canWrite ? null : (value) => _setTaskStatus(value), + ), + if (capabilities.supportsRecurrence) ...[ + const SizedBox(height: 12), + if (capabilities.supportsAdvancedRecurrence) + ListTile( + key: const ValueKey('android-task-advanced-recurrence'), + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.repeat), + title: Text(context.l10n.repeat), + subtitle: Text( + _taskRecurrence().isSupported + ? _recurrenceLabel(context, _taskRecurrence().frequency) + : context.l10n.unsupportedRecurrencePreserved, + ), + trailing: Icon( + _taskRecurrence().isSupported + ? Icons.chevron_right + : Icons.lock_outline, + ), + enabled: canWrite && _taskRecurrence().isSupported, + onTap: canWrite && _taskRecurrence().isSupported + ? _editAdvancedRecurrence + : null, + ) + else + DropdownButtonFormField( + initialValue: _recurrenceFrequency, + decoration: InputDecoration(labelText: context.l10n.repeat), + items: [ + for (final frequency in RecurrenceFrequency.values) + DropdownMenuItem( + value: frequency, + child: Text(_recurrenceLabel(context, frequency)), + ), + ], + onChanged: canWrite ? _setRecurrence : null, ), - DropdownMenuItem( - value: RecurrenceFrequency.daily, - child: Text(context.l10n.repeatDaily), + ], + if (capabilities.supportsCategories) ...[ + const SizedBox(height: 12), + TextField( + controller: _categories, + enabled: canWrite, + decoration: InputDecoration(labelText: context.l10n.categories), + onChanged: (v) => _draft = _draft.copyWith( + categories: v + .split(',') + .map((e) => e.trim()) + .where((e) => e.isNotEmpty) + .toList(), ), - DropdownMenuItem( - value: RecurrenceFrequency.weekly, - child: Text(context.l10n.repeatWeekly), + ), + ], + if (capabilities.supportsLocation) ...[ + const SizedBox(height: 12), + TextField( + controller: _location, + enabled: canWrite, + decoration: InputDecoration(labelText: context.l10n.location), + onChanged: (v) => _draft = _draft.copyWith(location: v), + ), + ], + if (capabilities.supportsUrl) ...[ + const SizedBox(height: 12), + TextField( + controller: _url, + enabled: canWrite, + keyboardType: TextInputType.url, + decoration: InputDecoration(labelText: context.l10n.taskUrl), + onChanged: (value) => _draft = _draft.copyWith(taskUrl: value), + ), + ], + if (capabilities.supportsClassification) + DropdownButtonFormField( + initialValue: _draft.classification, + decoration: InputDecoration( + labelText: context.l10n.classification, ), - DropdownMenuItem( - value: RecurrenceFrequency.monthly, - child: Text(context.l10n.repeatMonthly), + items: [ + DropdownMenuItem( + value: 'PUBLIC', + child: Text(context.l10n.classificationPublic), + ), + DropdownMenuItem( + value: 'CONFIDENTIAL', + child: Text(context.l10n.classificationConfidential), + ), + DropdownMenuItem( + value: 'PRIVATE', + child: Text(context.l10n.classificationPrivate), + ), + ], + onChanged: !canWrite || !capabilities.canUpdateClassification + ? null + : (value) => setState( + () => _draft = _draft.copyWith(classification: value), + ), + ), + if (capabilities.supportsPinning) + SwitchListTile( + contentPadding: EdgeInsets.zero, + title: Text(context.l10n.pinTask), + value: _draft.pinned, + onChanged: !canWrite + ? null + : (value) => setState( + () => _draft = _draft.copyWith(pinned: value), + ), + ), + if (capabilities.supportsSubtaskVisibility) ...[ + SwitchListTile( + contentPadding: EdgeInsets.zero, + title: Text(context.l10n.hideSubtasks), + value: _draft.hideSubtasks, + onChanged: !canWrite + ? null + : (value) => setState( + () => _draft = _draft.copyWith(hideSubtasks: value), + ), + ), + SwitchListTile( + contentPadding: EdgeInsets.zero, + title: Text(context.l10n.hideClosedSubtasks), + value: _draft.hideCompletedSubtasks, + onChanged: !canWrite + ? null + : (value) => setState( + () => _draft = _draft.copyWith( + hideCompletedSubtasks: value, + ), + ), + ), + ], + if (capabilities.supportsMultipleReminders) ...[ + const Divider(height: 32), + ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.notifications_outlined), + title: Text(context.l10n.reminderGroup), + trailing: IconButton( + tooltip: context.l10n.addReminder, + onPressed: canWrite ? _addAlarm : null, + icon: const Icon(Icons.add), ), - DropdownMenuItem( - value: RecurrenceFrequency.yearly, - child: Text(context.l10n.repeatYearly), + ), + for (var index = 0; index < _draft.alarms.length; index++) + ListTile( + contentPadding: EdgeInsets.zero, + title: Text(_alarmLabel(_draft.alarms[index])), + trailing: IconButton( + icon: const Icon(Icons.delete_outline), + onPressed: !canWrite + ? null + : () => setState( + () => _draft = _draft.copyWith( + alarms: [ + for (var i = 0; i < _draft.alarms.length; i++) + if (i != index) _draft.alarms[i], + ], + ), + ), + ), ), - ], - onChanged: _setRecurrence, - ), - ], - if (capabilities.supportsCategories) ...[ - const SizedBox(height: 12), - TextField( - controller: _categories, - decoration: InputDecoration(labelText: context.l10n.categories), - onChanged: (v) => _draft = _draft.copyWith( - categories: v - .split(',') - .map((e) => e.trim()) - .where((e) => e.isNotEmpty) - .toList(), + ], + if (widget.task != null && capabilities.supportsDuplicate) + OutlinedButton.icon( + onPressed: _saving || !capabilities.canCreateTasks + ? null + : _duplicate, + icon: const Icon(Icons.copy_outlined), + label: Text(context.l10n.duplicateTask), ), - ), - ], - if (capabilities.supportsLocation) ...[ - const SizedBox(height: 12), - TextField( - controller: _location, - decoration: InputDecoration(labelText: context.l10n.location), - onChanged: (v) => _draft = _draft.copyWith(location: v), - ), - ], - if (capabilities.supportsPercentComplete) - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(context.l10n.completionPercent(_draft.percentComplete)), - Slider( - value: _draft.percentComplete.toDouble(), - max: 100, - divisions: 20, - onChanged: (v) => setState( - () => _draft = _draft.copyWith(percentComplete: v.round()), + if (widget.task != null && capabilities.supportsCrossListMove) + OutlinedButton.icon( + onPressed: _saving || !capabilities.canUpdateTasks + ? null + : _moveToList, + icon: const Icon(Icons.drive_file_move_outline), + label: Text(context.l10n.list), + ), + if (widget.task != null && capabilities.supportsNativeExport) + OutlinedButton.icon( + onPressed: _saving ? null : _export, + icon: const Icon(Icons.file_download_outlined), + label: Text(context.l10n.export), + ), + if (capabilities.supportsPercentComplete) + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(context.l10n.completionPercent(_draft.percentComplete)), + Slider( + value: _draft.percentComplete.toDouble(), + max: 100, + divisions: 20, + onChanged: !canWrite + ? null + : (v) => _setTaskProgress(v.round()), ), + ], + ), + if (capabilities.supportsCompletedDateTime && + (_draft.taskStatus == 'COMPLETED' || + _draft.percentComplete == 100 || + _draft.completedDate != null)) ...[ + ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.event_available_outlined), + title: Text(context.l10n.completionDate), + subtitle: Text(_draft.completedDate ?? context.l10n.noDate), + trailing: _draft.completedDate == null + ? null + : IconButton( + icon: const Icon(Icons.clear), + onPressed: canWrite ? _clearCompletionDate : null, + ), + enabled: canWrite, + onTap: canWrite ? _pickCompletionDate : null, + ), + if (_draft.completedDate != null) + ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.schedule), + title: Text(context.l10n.completed), + subtitle: Text(_draft.completedTime ?? context.l10n.noDate), + enabled: canWrite, + onTap: canWrite ? _pickCompletionTime : null, ), - ], - ), - if (widget.task != null) ...[ - const SizedBox(height: 24), - OutlinedButton.icon( - onPressed: _saving ? null : _delete, - icon: const Icon(Icons.delete_outline), - label: Text(context.l10n.deleteTask), - ), + ], + if (widget.task != null && capabilities.supportsTaskHierarchy) ...[ + const Divider(height: 32), + StreamBuilder( + stream: ref + .read(tasksRepositoryForAccountProvider(widget.accountId)) + .watchTaskHierarchy(_draft.taskListId, widget.task!.id), + builder: (context, snapshot) => _taskHierarchySection( + snapshot.data ?? + const TaskHierarchySnapshot(parent: null, subtasks: []), + capabilities, + ), + ), + ], + if (widget.task != null && + capabilities.supportsTaskReparenting && + (widget.task!.parent != null || widget.task!.parentUid != null)) + OutlinedButton.icon( + onPressed: _saving || !capabilities.canUpdateTasks + ? null + : _moveToTop, + icon: const Icon(Icons.vertical_align_top), + label: Text(context.l10n.moveToTop), + ), + if (widget.task != null && capabilities.canDeleteTasks) ...[ + const SizedBox(height: 24), + OutlinedButton.icon( + onPressed: _saving ? null : _delete, + icon: const Icon(Icons.delete_outline), + label: Text(context.l10n.deleteTask), + ), + ], + const SizedBox(height: 40), ], - const SizedBox(height: 40), - ], + ), ), ); } @@ -599,87 +1031,495 @@ class _AndroidTaskEditorState extends ConsumerState { } } - Future _pickDueTime() async { - final parts = (_draft.microsoftDueTime ?? '09:00').split(':'); - final value = await showTimePicker( - context: context, - initialTime: TimeOfDay( - hour: int.parse(parts[0]), - minute: int.parse(parts[1]), + Widget _taskHierarchySection( + TaskHierarchySnapshot hierarchy, + TaskCollectionCapabilities capabilities, + ) => Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + context.l10n.subtasks, + style: Theme.of(context).textTheme.titleMedium, ), - ); - if (value != null) { - setState( - () => _draft = _draft.copyWith( - microsoftDueTime: - '${value.hour.toString().padLeft(2, '0')}:${value.minute.toString().padLeft(2, '0')}', + if (hierarchy.parent case final parent?) + ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.account_tree_outlined), + title: Text(parent.title), + subtitle: Text(context.l10n.parent), + onTap: () => _openHierarchyTask(parent), ), - ); - } - } + for (final subtask in hierarchy.subtasks) + ListTile( + contentPadding: EdgeInsets.zero, + leading: IconButton( + onPressed: capabilities.canUpdateTasks + ? () => _toggleSubtask(subtask) + : null, + icon: Icon( + subtask.completed + ? Icons.check_circle + : Icons.radio_button_unchecked, + ), + ), + title: Text(subtask.title), + trailing: subtask.hasChildren + ? const Icon(Icons.account_tree_outlined) + : null, + onTap: subtask.task == null + ? null + : () => _openHierarchyTask(subtask.task!), + ), + OutlinedButton.icon( + onPressed: capabilities.canCreateTasks ? _createSubtask : null, + icon: const Icon(Icons.add_task), + label: Text(context.l10n.createSubtask), + ), + ], + ); - Future _save() async { - _draft = _draft.copyWith( - title: _title.text.trim(), - notes: _notes.text, - location: _location.text, - categories: _categories.text - .split(',') - .map((e) => e.trim()) - .where((e) => e.isNotEmpty) - .toList(), + Future _openHierarchyTask(TaskEntity task) async { + await Navigator.of(context).push( + MaterialPageRoute( + fullscreenDialog: true, + builder: (_) => AndroidTaskEditor( + accountId: widget.accountId, + provider: widget.provider, + task: task, + accountLabel: widget.accountLabel, + listLabel: widget.listLabel, + ), + ), ); - if (_draft.title.trim().isEmpty || - !_draft.hasValidTaskUrlFor(_capabilities) || - _draft.scheduleIssueFor(_capabilities) != TaskScheduleIssue.none) { - return; - } - setState(() => _saving = true); + } + + Future _toggleSubtask(TaskSubtaskEntity subtask) async { try { final repository = ref.read( tasksRepositoryForAccountProvider(widget.accountId), ); - if (widget.task == null) { - await repository.createTask( - _draft.taskListId, - _draft.toCreateInput( - _capabilities, - localTimeZone: ref.read(localTimeZoneProvider), - ), + if (subtask.kind == TaskSubtaskKind.checklistItem) { + await repository.patchChecklistSubtask( + taskListId: _draft.taskListId, + parentTaskId: widget.task!.id, + checklistItemId: subtask.id, + completed: !subtask.completed, ); } else { await repository.patchTask( _draft.taskListId, - _draft.taskId, - TaskPatchInput( - _draft.toPatch( - widget.task!, - _capabilities, - localTimeZone: ref.read(localTimeZoneProvider), - ), - ), + subtask.id, + TaskPatchInput({ + 'status': subtask.completed ? 'needsAction' : 'completed', + }), ); } - if (mounted) Navigator.pop(context); } on Object catch (error) { if (mounted) { ScaffoldMessenger.of( context, ).showSnackBar(SnackBar(content: Text('$error'))); } - } finally { - if (mounted) setState(() => _saving = false); } } - RecurrenceRule _taskRecurrence() { - final creation = _draft.creationRecurrence; - if (creation != null) return creation.rule; - final raw = _draft.recurrenceJson; - if (raw == null) return const RecurrenceRule.none(); + Future _createSubtask() async { + final controller = TextEditingController(); + final title = await showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: Text(context.l10n.createSubtask), + content: TextField( + controller: controller, + autofocus: true, + textInputAction: TextInputAction.done, + decoration: InputDecoration(labelText: context.l10n.title), + onSubmitted: (value) { + if (value.trim().isNotEmpty) Navigator.pop(dialogContext, value); + }, + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext), + child: Text(context.l10n.cancel), + ), + FilledButton( + onPressed: () { + if (controller.text.trim().isNotEmpty) { + Navigator.pop(dialogContext, controller.text); + } + }, + child: Text(context.l10n.create), + ), + ], + ), + ); + controller.dispose(); + if (title == null) return; try { - return EventRecurrenceCodec.decode( - widget.provider, + await ref + .read(tasksRepositoryForAccountProvider(widget.accountId)) + .createSubtask( + taskListId: _draft.taskListId, + parentTaskId: widget.task!.id, + title: title, + ); + } on Object catch (error) { + if (mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('$error'))); + } + } + } + + Future _pickDueTime() async { + final parts = (_draft.microsoftDueTime ?? '09:00').split(':'); + final value = await showTimePicker( + context: context, + initialTime: TimeOfDay( + hour: int.parse(parts[0]), + minute: int.parse(parts[1]), + ), + ); + if (value != null) { + setState( + () => _draft = _draft.copyWith( + microsoftDueTime: + '${value.hour.toString().padLeft(2, '0')}:${value.minute.toString().padLeft(2, '0')}', + ), + ); + } + } + + Future _pickStartDate() async { + final current = + DateTime.tryParse(_draft.microsoftStartDate ?? '') ?? DateTime.now(); + final value = await showDatePicker( + context: context, + initialDate: current, + firstDate: DateTime(1970), + lastDate: DateTime(2200), + ); + if (value != null) { + setState( + () => _draft = _draft.copyWith(microsoftStartDate: _date(value)), + ); + } + } + + Future _pickStartTime() async { + final value = await _pickTime(_draft.microsoftStartTime); + if (value != null) { + setState(() => _draft = _draft.copyWith(microsoftStartTime: value)); + } + } + + Future _pickReminderDate() async { + final current = + DateTime.tryParse(_draft.microsoftReminderDate ?? '') ?? DateTime.now(); + final value = await showDatePicker( + context: context, + initialDate: current, + firstDate: DateTime(1970), + lastDate: DateTime(2200), + ); + if (value != null) { + setState( + () => _draft = _draft.copyWith(microsoftReminderDate: _date(value)), + ); + } + } + + Future _pickReminderTime() async { + final value = await _pickTime(_draft.microsoftReminderTime); + if (value != null) { + setState(() => _draft = _draft.copyWith(microsoftReminderTime: value)); + } + } + + Future _pickTime(String? current) async { + final parts = (current ?? '09:00').split(':'); + final value = await showTimePicker( + context: context, + initialTime: TimeOfDay( + hour: int.tryParse(parts.first) ?? 9, + minute: parts.length > 1 ? int.tryParse(parts[1]) ?? 0 : 0, + ), + ); + return value == null + ? null + : '${value.hour.toString().padLeft(2, '0')}:${value.minute.toString().padLeft(2, '0')}'; + } + + void _setTaskStatus(String? status) { + final now = DateTime.now(); + setState(() { + _draft = switch (status) { + 'COMPLETED' => _draft.copyWith( + taskStatus: status, + percentComplete: 100, + completedDate: _draft.completedDate ?? _date(now), + completedTime: _draft.completedTime ?? _clock(now), + ), + 'IN-PROCESS' => _draft.copyWith( + taskStatus: status, + percentComplete: _draft.percentComplete == 0 + ? 1 + : _draft.percentComplete == 100 + ? 99 + : _draft.percentComplete, + completedDate: null, + completedTime: null, + ), + 'NEEDS-ACTION' || null => _draft.copyWith( + taskStatus: status, + percentComplete: _draft.percentComplete == 100 + ? 99 + : _draft.percentComplete, + completedDate: null, + completedTime: null, + ), + 'CANCELLED' => _draft.copyWith(taskStatus: status), + _ => _draft, + }; + }); + } + + void _setTaskProgress(int percent) { + final now = DateTime.now(); + setState(() { + _draft = switch (percent.clamp(0, 100)) { + 100 => _draft.copyWith( + percentComplete: 100, + taskStatus: 'COMPLETED', + completedDate: _draft.completedDate ?? _date(now), + completedTime: _draft.completedTime ?? _clock(now), + ), + 0 => _draft.copyWith( + percentComplete: 0, + taskStatus: 'NEEDS-ACTION', + completedDate: null, + completedTime: null, + ), + final value => _draft.copyWith( + percentComplete: value, + taskStatus: 'IN-PROCESS', + completedDate: null, + completedTime: null, + ), + }; + }); + } + + Future _pickCompletionDate() async { + final current = + DateTime.tryParse(_draft.completedDate ?? '') ?? DateTime.now(); + final value = await showDatePicker( + context: context, + initialDate: current, + firstDate: DateTime(1970), + lastDate: DateTime(2200), + ); + if (value == null) return; + setState( + () => _draft = _draft.copyWith( + completedDate: _date(value), + completedTime: _draft.completedTime ?? _clock(DateTime.now()), + percentComplete: 100, + taskStatus: 'COMPLETED', + ), + ); + } + + Future _pickCompletionTime() async { + final value = await _pickTime(_draft.completedTime); + if (value != null) { + setState(() => _draft = _draft.copyWith(completedTime: value)); + } + } + + void _clearCompletionDate() => setState( + () => _draft = _draft.copyWith( + completedDate: null, + completedTime: null, + percentComplete: _draft.percentComplete == 100 + ? 99 + : _draft.percentComplete, + taskStatus: _draft.percentComplete == 100 + ? 'IN-PROCESS' + : _draft.taskStatus, + ), + ); + + Future _addAlarm() async { + final mode = await showModalBottomSheet( + context: context, + showDragHandle: true, + builder: (sheetContext) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + leading: const Icon(Icons.event_outlined), + title: Text(context.l10n.absoluteReminder), + onTap: () => Navigator.pop(sheetContext, 'absolute'), + ), + if (_draft.microsoftStartDate != null) + ListTile( + leading: const Icon(Icons.play_arrow_outlined), + title: Text(context.l10n.beforeTaskStarts), + onTap: () => Navigator.pop(sheetContext, 'start'), + ), + if (_draft.dueDate != null) + ListTile( + leading: const Icon(Icons.flag_outlined), + title: Text(context.l10n.beforeTaskDue), + onTap: () => Navigator.pop(sheetContext, 'due'), + ), + ], + ), + ), + ); + if (mode == null || !mounted) return; + IcalTaskAlarm? alarm; + if (mode == 'absolute') { + final date = await showDatePicker( + context: context, + initialDate: DateTime.now(), + firstDate: DateTime(1970), + lastDate: DateTime(2200), + ); + if (date == null || !mounted) return; + final time = await showTimePicker( + context: context, + initialTime: TimeOfDay.now(), + ); + if (time == null) return; + alarm = IcalTaskAlarm.displayAbsolute( + DateTime( + date.year, + date.month, + date.day, + time.hour, + time.minute, + ).toUtc(), + ); + } else { + final allDay = + _draft.microsoftDueTime == null && _draft.microsoftStartTime == null; + final selection = + await showDialog<({int amount, String unit, TimeOfDay? time})>( + context: context, + builder: (_) => _AndroidRelativeAlarmDialog(allDay: allDay), + ); + if (selection == null) return; + final duration = allDay + ? IcalAllDayAlarmOffset( + amount: selection.amount, + unit: selection.unit == 'weeks' + ? IcalAllDayAlarmUnit.weeks + : IcalAllDayAlarmUnit.days, + hour: selection.time?.hour ?? 9, + minute: selection.time?.minute ?? 0, + ).toDuration() + : -switch (selection.unit) { + 'weeks' => Duration(days: selection.amount * 7), + 'days' => Duration(days: selection.amount), + 'hours' => Duration(hours: selection.amount), + _ => Duration(minutes: selection.amount), + }; + alarm = IcalTaskAlarm.displayRelative( + duration, + relatedToDue: mode == 'due', + ); + } + setState( + () => _draft = _draft.copyWith(alarms: [..._draft.alarms, alarm!]), + ); + } + + String _alarmLabel(IcalTaskAlarm alarm) { + final absolute = alarm.absoluteUtc; + if (absolute != null) { + return DateFormat.yMMMd().add_jm().format(absolute.toLocal()); + } + return alarm.triggerRaw; + } + + Future _save() async { + _draft = _draft.copyWith( + title: _title.text.trim(), + notes: _notes.text, + location: _location.text, + taskUrl: _url.text.trim(), + categories: _categories.text + .split(',') + .map((e) => e.trim()) + .where((e) => e.isNotEmpty) + .toList(), + ); + if (_draft.title.trim().isEmpty || + !_draft.hasValidTaskUrlFor(_capabilities) || + _draft.scheduleIssueFor(_capabilities) != TaskScheduleIssue.none) { + return; + } + setState(() => _saving = true); + try { + if (_draft.microsoftReminderEnabled || _draft.alarms.isNotEmpty) { + await ref + .read(androidNotificationServiceProvider) + .requestNotificationPermission(); + } + final repository = ref.read( + tasksRepositoryForAccountProvider(widget.accountId), + ); + if (widget.task == null) { + await repository.createTask( + _draft.taskListId, + _draft.toCreateInput( + _capabilities, + localTimeZone: ref.read(localTimeZoneProvider), + ), + ); + } else { + await repository.patchTask( + _draft.taskListId, + _draft.taskId, + TaskPatchInput( + _draft.toPatch( + widget.task!, + _capabilities, + localTimeZone: ref.read(localTimeZoneProvider), + ), + ), + ); + } + if (mounted) { + await _clearRecovery(); + if (!mounted) return; + setState(() => _allowPop = true); + Navigator.pop(context); + } + } on Object catch (error) { + if (mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('$error'))); + } + } finally { + if (mounted) setState(() => _saving = false); + } + } + + RecurrenceRule _taskRecurrence() { + final creation = _draft.creationRecurrence; + if (creation != null) return creation.rule; + final raw = _draft.recurrenceJson; + if (raw == null) return const RecurrenceRule.none(); + try { + return EventRecurrenceCodec.decode( + widget.provider, jsonDecode(raw), baseDate: DateTime.tryParse(_draft.dueDate ?? ''), ); @@ -692,8 +1532,33 @@ class _AndroidTaskEditorState extends ConsumerState { if (frequency == null) return; final base = DateTime.tryParse(_draft.dueDate ?? '') ?? DateTime.now(); final rule = _simpleTaskRecurrenceRule(frequency, base); + _applyRecurrence(rule, base); + } + + Future _editAdvancedRecurrence() async { + final initial = _taskRecurrence(); + if (!initial.isSupported) return; + final base = + DateTime.tryParse(_draft.microsoftStartDate ?? _draft.dueDate ?? '') ?? + DateTime.now(); + final result = await showDialog( + context: context, + builder: (_) => _AndroidRecurrenceDialog( + initial: initial, + baseDate: base, + provider: widget.provider, + allDay: + _draft.microsoftDueTime == null && + _draft.microsoftStartTime == null, + timeZone: _draft.microsoftStartTimeZone, + ), + ); + if (result != null && mounted) _applyRecurrence(result, base); + } + + void _applyRecurrence(RecurrenceRule rule, DateTime base) { setState(() { - _recurrenceFrequency = frequency; + _recurrenceFrequency = rule.frequency; if (widget.task == null) { _draft = _draft.copyWith( creationRecurrence: (provider: widget.provider, rule: rule), @@ -742,7 +1607,12 @@ class _AndroidTaskEditorState extends ConsumerState { await ref .read(tasksRepositoryForAccountProvider(widget.accountId)) .deleteTask(widget.task!.taskListId, widget.task!.id); - if (mounted) Navigator.pop(context); + if (mounted) { + await _clearRecovery(); + if (!mounted) return; + setState(() => _allowPop = true); + Navigator.pop(context); + } } on Object catch (error) { if (mounted) { ScaffoldMessenger.of( @@ -753,6 +1623,687 @@ class _AndroidTaskEditorState extends ConsumerState { if (mounted) setState(() => _saving = false); } } + + Future _duplicate() async { + setState(() => _saving = true); + try { + await ref + .read(tasksRepositoryForAccountProvider(widget.accountId)) + .duplicateTask(_draft.taskListId, widget.task!.id); + if (mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(context.l10n.duplicateTask))); + } + } on Object catch (error) { + if (mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('$error'))); + } + } finally { + if (mounted) setState(() => _saving = false); + } + } + + Future _moveToList() async { + final lists = + (await ref + .read(taskListsRepositoryForAccountProvider(widget.accountId)) + .listTaskLists()) + .where((list) => list.id != _draft.taskListId) + .toList(); + if (!mounted || lists.isEmpty) return; + final destination = await selectAndroidTaskList( + context, + lists, + title: context.l10n.list, + accountLabels: { + widget.accountId: widget.accountLabel ?? widget.accountId, + }, + ); + if (destination == null || !mounted) return; + setState(() => _saving = true); + try { + await ref + .read(tasksRepositoryForAccountProvider(widget.accountId)) + .moveTask( + TaskMoveInput( + sourceTaskListId: _draft.taskListId, + destinationTaskListId: destination.id, + taskId: widget.task!.id, + ), + ); + await _clearRecovery(); + if (!mounted) return; + setState(() => _allowPop = true); + Navigator.pop(context); + } on Object catch (error) { + if (mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('$error'))); + } + } finally { + if (mounted) setState(() => _saving = false); + } + } + + Future _moveToTop() async { + setState(() => _saving = true); + try { + await ref + .read(tasksRepositoryForAccountProvider(widget.accountId)) + .moveTask( + TaskMoveInput( + sourceTaskListId: _draft.taskListId, + taskId: widget.task!.id, + ), + ); + if (mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(context.l10n.moveToTop))); + } + } on Object catch (error) { + if (mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('$error'))); + } + } finally { + if (mounted) setState(() => _saving = false); + } + } + + Future _export() async { + try { + final raw = await ref + .read(tasksRepositoryForAccountProvider(widget.accountId)) + .nativeTaskExport(_draft.taskListId, widget.task!.id); + if (raw == null) return; + await BusyMaxAndroidPlatform.instance.createDocument( + suggestedName: 'task.ics', + mimeType: 'text/calendar', + bytes: Uint8List.fromList(utf8.encode(raw)), + ); + } on Object catch (error) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.exportFailed('$error'))), + ); + } + } + } + + Future _requestClose() async { + if (!_hasPendingEdits || await _confirmDiscardChanges()) { + if (!mounted) return; + await _clearRecovery(); + if (!mounted) return; + setState(() => _allowPop = true); + Navigator.pop(context); + } + } + + Future _confirmDiscardChanges() async => + await showDialog( + context: context, + barrierDismissible: false, + builder: (dialogContext) => AlertDialog( + title: Text(context.l10n.discardChanges), + content: Text(context.l10n.discardChangesConfirmation), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext, false), + child: Text(context.l10n.cancel), + ), + FilledButton( + onPressed: () => Navigator.pop(dialogContext, true), + child: Text(context.l10n.discardChangesAction), + ), + ], + ), + ) ?? + false; + + Future _restoreRecovery() async { + try { + final raw = await ref.read(secureStorageProvider).read(key: _recoveryKey); + if (raw == null) return; + final value = jsonDecode(raw); + if (value is! Map) return; + final map = value.cast(); + final savedAt = DateTime.tryParse(map['savedAt']?.toString() ?? ''); + if (savedAt == null || + DateTime.now().difference(savedAt).abs() > const Duration(days: 14) || + map['baseTitle'] != _initialDraft.title || + map['taskListId'] != _initialDraft.taskListId) { + await _clearRecovery(); + return; + } + final frequency = RecurrenceFrequency.values + .where((value) => value.name == map['frequency']) + .firstOrNull; + final alarms = []; + for (final rawAlarm in map['alarms'] as List? ?? const []) { + if (rawAlarm is Map) { + alarms.add(IcalTaskAlarm.fromJson(rawAlarm.cast())); + } + } + var recovered = _draft.copyWith( + title: map['title']?.toString() ?? _draft.title, + notes: map['notes']?.toString() ?? _draft.notes, + dueDate: map['dueDate'], + microsoftDueTime: map['dueTime'], + microsoftStartDate: map['startDate'], + microsoftStartTime: map['startTime'], + microsoftReminderEnabled: + map['reminderEnabled'] as bool? ?? _draft.microsoftReminderEnabled, + microsoftReminderDate: map['reminderDate'], + microsoftReminderTime: map['reminderTime'], + recurrenceJson: map['recurrenceJson'], + importance: map['importance']?.toString(), + categories: (map['categories'] as List? ?? const []) + .map((value) => value.toString()) + .toList(), + icalPriority: map['icalPriority'] as int?, + percentComplete: map['percentComplete'] as int?, + taskStatus: map['taskStatus'], + completedDate: map['completedDate'], + completedTime: map['completedTime'], + location: map['location']?.toString(), + taskUrl: map['taskUrl']?.toString(), + classification: map['classification']?.toString(), + pinned: map['pinned'] as bool?, + hideSubtasks: map['hideSubtasks'] as bool?, + hideCompletedSubtasks: map['hideCompletedSubtasks'] as bool?, + alarms: alarms, + ); + if (frequency != null && widget.task == null) { + final base = + DateTime.tryParse(recovered.dueDate ?? '') ?? DateTime.now(); + recovered = recovered.copyWith( + creationRecurrence: ( + provider: widget.provider, + rule: _simpleTaskRecurrenceRule(frequency, base), + ), + ); + } + if (!mounted) return; + await WidgetsBinding.instance.endOfFrame; + if (!mounted) return; + final recover = await showDialog( + context: context, + barrierDismissible: false, + builder: (dialogContext) => AlertDialog( + title: Text(context.l10n.discardChanges), + content: Text(context.l10n.discardChangesConfirmation), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext, false), + child: Text(context.l10n.discardChangesAction), + ), + FilledButton( + onPressed: () => Navigator.pop(dialogContext, true), + child: Text(context.l10n.nextcloudRestore), + ), + ], + ), + ); + if (recover != true) { + await _clearRecovery(); + return; + } + if (!mounted) return; + setState(() { + _draft = recovered; + _recurrenceFrequency = frequency ?? _recurrenceFrequency; + _title.text = recovered.title; + _notes.text = recovered.notes; + _location.text = recovered.location; + _url.text = recovered.taskUrl; + _categories.text = recovered.categories.join(', '); + }); + } on Object { + // A malformed/locked recovery entry must never block the editor. + } finally { + _recoveryLoaded = true; + } + } + + Future _persistRecovery() async { + if (!_recoveryLoaded || !_hasPendingEdits || _allowPop) return; + await ref + .read(secureStorageProvider) + .write( + key: _recoveryKey, + value: jsonEncode({ + 'v': 1, + 'savedAt': DateTime.now().toIso8601String(), + 'baseTitle': _initialDraft.title, + 'taskListId': _draft.taskListId, + 'title': _draft.title, + 'notes': _draft.notes, + 'dueDate': _draft.dueDate, + 'dueTime': _draft.microsoftDueTime, + 'startDate': _draft.microsoftStartDate, + 'startTime': _draft.microsoftStartTime, + 'reminderEnabled': _draft.microsoftReminderEnabled, + 'reminderDate': _draft.microsoftReminderDate, + 'reminderTime': _draft.microsoftReminderTime, + 'recurrenceJson': _draft.recurrenceJson, + 'frequency': _recurrenceFrequency.name, + 'importance': _draft.importance, + 'categories': _draft.categories, + 'icalPriority': _draft.icalPriority, + 'percentComplete': _draft.percentComplete, + 'taskStatus': _draft.taskStatus, + 'completedDate': _draft.completedDate, + 'completedTime': _draft.completedTime, + 'location': _draft.location, + 'taskUrl': _draft.taskUrl, + 'classification': _draft.classification, + 'pinned': _draft.pinned, + 'hideSubtasks': _draft.hideSubtasks, + 'hideCompletedSubtasks': _draft.hideCompletedSubtasks, + 'alarms': [for (final alarm in _draft.alarms) alarm.toJson()], + }), + ); + } + + Future _clearRecovery() => + ref.read(secureStorageProvider).delete(key: _recoveryKey); +} + +class _AndroidRelativeAlarmDialog extends StatefulWidget { + const _AndroidRelativeAlarmDialog({required this.allDay}); + + final bool allDay; + + @override + State<_AndroidRelativeAlarmDialog> createState() => + _AndroidRelativeAlarmDialogState(); +} + +class _AndroidRelativeAlarmDialogState + extends State<_AndroidRelativeAlarmDialog> { + final _amount = TextEditingController(text: '10'); + late String _unit = widget.allDay ? 'days' : 'minutes'; + TimeOfDay _time = const TimeOfDay(hour: 9, minute: 0); + + @override + void dispose() { + _amount.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final units = widget.allDay + ? const ['days', 'weeks'] + : const ['minutes', 'hours', 'days', 'weeks']; + return AlertDialog( + title: Text(context.l10n.addReminder), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField( + controller: _amount, + autofocus: true, + keyboardType: TextInputType.number, + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + decoration: InputDecoration(labelText: context.l10n.reminderAmount), + onChanged: (_) => setState(() {}), + ), + const SizedBox(height: 12), + DropdownButtonFormField( + initialValue: _unit, + decoration: InputDecoration(labelText: context.l10n.reminderUnit), + items: [ + for (final unit in units) + DropdownMenuItem( + value: unit, + child: Text(switch (unit) { + 'weeks' => context.l10n.reminderUnitWeeks, + 'days' => context.l10n.reminderUnitDays, + 'hours' => context.l10n.reminderUnitHours, + _ => context.l10n.reminderUnitMinutes, + }), + ), + ], + onChanged: (value) => setState(() => _unit = value ?? _unit), + ), + if (widget.allDay) + ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.schedule), + title: Text(context.l10n.reminderTimeOfDay), + subtitle: Text(_time.format(context)), + onTap: () async { + final value = await showTimePicker( + context: context, + initialTime: _time, + ); + if (value != null) setState(() => _time = value); + }, + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text(context.l10n.cancel), + ), + FilledButton( + onPressed: switch (int.tryParse(_amount.text)) { + final value? when value >= 0 && value <= 3600 => + () => Navigator.pop(context, ( + amount: value, + unit: _unit, + time: widget.allDay ? _time : null, + )), + _ => null, + }, + child: Text(context.l10n.save), + ), + ], + ); + } +} + +enum _AndroidRecurrenceEnd { never, until, count } + +class _AndroidRecurrenceDialog extends StatefulWidget { + const _AndroidRecurrenceDialog({ + required this.initial, + required this.baseDate, + required this.provider, + required this.allDay, + required this.timeZone, + }); + + final RecurrenceRule initial; + final DateTime baseDate; + final BusyProvider provider; + final bool allDay; + final String? timeZone; + + @override + State<_AndroidRecurrenceDialog> createState() => + _AndroidRecurrenceDialogState(); +} + +class _AndroidRecurrenceDialogState extends State<_AndroidRecurrenceDialog> { + late RecurrenceRule _value = widget.initial; + late _AndroidRecurrenceEnd _end = _value.count != null + ? _AndroidRecurrenceEnd.count + : _value.untilRaw != null + ? _AndroidRecurrenceEnd.until + : _AndroidRecurrenceEnd.never; + late final _interval = TextEditingController(text: '${_value.interval}'); + late final _count = TextEditingController(text: '${_value.count ?? 10}'); + late DateTime _until = + DateTime.tryParse(_value.untilDate ?? '') ?? + DateTime( + widget.baseDate.year, + widget.baseDate.month + 1, + widget.baseDate.day, + ); + + @override + void dispose() { + _interval.dispose(); + _count.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) => AlertDialog( + title: Text(context.l10n.repeat), + content: SizedBox( + width: 560, + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + DropdownButtonFormField( + initialValue: _value.frequency, + decoration: InputDecoration(labelText: context.l10n.repeat), + items: [ + for (final frequency in RecurrenceFrequency.values) + DropdownMenuItem( + value: frequency, + child: Text(_recurrenceLabel(context, frequency)), + ), + ], + onChanged: (frequency) { + if (frequency == null) return; + setState(() { + final replacement = _simpleTaskRecurrenceRule( + frequency, + widget.baseDate, + ); + _value = replacement.copyWith( + interval: int.tryParse(_interval.text) ?? 1, + count: _value.count, + untilRaw: _value.untilRaw, + ); + }); + }, + ), + if (_value.repeats) ...[ + const SizedBox(height: 12), + TextField( + controller: _interval, + keyboardType: TextInputType.number, + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + decoration: InputDecoration( + labelText: context.l10n.repeatEvery, + ), + onChanged: (_) => setState(() {}), + ), + ], + if (_value.frequency == RecurrenceFrequency.weekly) ...[ + const SizedBox(height: 16), + Text(context.l10n.repeatOn), + const SizedBox(height: 4), + Wrap( + spacing: 6, + children: [ + for (var index = 0; index < rfcWeekdays.length; index++) + FilterChip( + label: Text( + DateFormat.E().format(DateTime(2026, 1, 5 + index)), + ), + selected: _value.byDay.contains(rfcWeekdays[index]), + onSelected: (selected) => setState(() { + final values = [..._value.byDay]; + if (selected) { + values.add(rfcWeekdays[index]); + } else { + values.remove(rfcWeekdays[index]); + } + _value = _value.copyWith(byDay: values); + }), + ), + ], + ), + ], + if (_value.frequency == RecurrenceFrequency.monthly || + _value.frequency == RecurrenceFrequency.yearly) ...[ + const SizedBox(height: 16), + Text(context.l10n.repeatDayOfMonth), + const SizedBox(height: 4), + Wrap( + spacing: 4, + children: [ + for (var day = 1; day <= 31; day++) + FilterChip( + visualDensity: VisualDensity.compact, + label: Text('$day'), + selected: _value.byMonthDay.contains(day), + onSelected: (selected) => setState(() { + final values = [..._value.byMonthDay]; + if (selected) { + values.add(day); + } else { + values.remove(day); + } + values.sort(); + _value = _value.copyWith( + byMonthDay: values, + byDay: const [], + bySetPosition: null, + ); + }), + ), + ], + ), + ], + if (_value.frequency == RecurrenceFrequency.yearly) ...[ + const SizedBox(height: 16), + Text(context.l10n.repeatMonths), + const SizedBox(height: 4), + Wrap( + spacing: 4, + children: [ + for (var month = 1; month <= 12; month++) + FilterChip( + visualDensity: VisualDensity.compact, + label: Text( + DateFormat.MMM().format(DateTime(2026, month)), + ), + selected: _value.byMonth.contains(month), + onSelected: (selected) => setState(() { + final values = [..._value.byMonth]; + if (selected) { + values.add(month); + } else { + values.remove(month); + } + values.sort(); + _value = _value.copyWith(byMonth: values); + }), + ), + ], + ), + ], + if (_value.repeats) ...[ + const SizedBox(height: 16), + DropdownButtonFormField<_AndroidRecurrenceEnd>( + initialValue: _end, + decoration: InputDecoration(labelText: context.l10n.repeatEnd), + items: [ + DropdownMenuItem( + value: _AndroidRecurrenceEnd.never, + child: Text(context.l10n.repeatNever), + ), + DropdownMenuItem( + value: _AndroidRecurrenceEnd.until, + child: Text(context.l10n.repeatUntil), + ), + DropdownMenuItem( + value: _AndroidRecurrenceEnd.count, + child: Text(context.l10n.repeatAfter), + ), + ], + onChanged: (value) => + setState(() => _end = value ?? _AndroidRecurrenceEnd.never), + ), + if (_end == _AndroidRecurrenceEnd.count) ...[ + const SizedBox(height: 12), + TextField( + controller: _count, + keyboardType: TextInputType.number, + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + decoration: InputDecoration( + labelText: context.l10n.repeatCount, + ), + onChanged: (_) => setState(() {}), + ), + ], + if (_end == _AndroidRecurrenceEnd.until) ...[ + const SizedBox(height: 8), + ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.event_outlined), + title: Text(context.l10n.repeatUntil), + subtitle: Text(_date(_until)), + onTap: _pickUntil, + ), + ], + ], + ], + ), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text(context.l10n.cancel), + ), + FilledButton( + onPressed: _candidate == null + ? null + : () => Navigator.pop(context, _candidate), + child: Text(context.l10n.save), + ), + ], + ); + + RecurrenceRule? get _candidate { + if (!_value.repeats) return const RecurrenceRule.none(); + final interval = int.tryParse(_interval.text); + if (interval == null || interval < 1 || interval > 366) return null; + RecurrenceRule result = _value.copyWith( + interval: interval, + count: _end == _AndroidRecurrenceEnd.count + ? int.tryParse(_count.text) + : null, + untilRaw: null, + ); + if (_end == _AndroidRecurrenceEnd.count && + (result.count == null || result.count! < 1 || result.count! > 3500)) { + return null; + } + if (_end == _AndroidRecurrenceEnd.until) { + if (_until.isBefore( + DateTime( + widget.baseDate.year, + widget.baseDate.month, + widget.baseDate.day, + ), + )) { + return null; + } + result = result.withUntilDate( + _date(_until), + allDay: widget.allDay, + floating: !widget.allDay && (widget.timeZone?.trim().isEmpty ?? true), + baseDate: widget.baseDate, + timeZone: widget.timeZone, + ); + } + return EventRecurrenceCodec.canEncode(widget.provider, result) + ? result + : null; + } + + Future _pickUntil() async { + final value = await showDatePicker( + context: context, + initialDate: _until, + firstDate: DateTime( + widget.baseDate.year, + widget.baseDate.month, + widget.baseDate.day, + ), + lastDate: DateTime(2200), + ); + if (value != null) setState(() => _until = value); + } } String _taskListKey(String accountId, String listId) => @@ -776,6 +2327,13 @@ Future showAndroidTaskById( final account = await ref .read(accountsRepositoryProvider) .accountById(accountId); + final list = row == null + ? null + : (await ref + .read(taskListsRepositoryForAccountProvider(accountId)) + .listTaskLists()) + .where((value) => value.id == row.taskListId) + .firstOrNull; if (!context.mounted || row == null || account == null) return; await Navigator.of(context).push( MaterialPageRoute( @@ -784,6 +2342,8 @@ Future showAndroidTaskById( accountId: accountId, provider: account.provider, task: TaskEntity.fromRow(row), + accountLabel: account.displayLabel, + listLabel: list?.title, ), ), ); @@ -816,4 +2376,15 @@ RecurrenceRule _simpleTaskRecurrenceRule( String _date(DateTime value) => '${value.year.toString().padLeft(4, '0')}-${value.month.toString().padLeft(2, '0')}-${value.day.toString().padLeft(2, '0')}'; +String _clock(DateTime value) => + '${value.hour.toString().padLeft(2, '0')}:${value.minute.toString().padLeft(2, '0')}'; String _today() => _date(DateTime.now()); + +String _recurrenceLabel(BuildContext context, RecurrenceFrequency frequency) => + switch (frequency) { + RecurrenceFrequency.none => context.l10n.repeatNone, + RecurrenceFrequency.daily => context.l10n.repeatDaily, + RecurrenceFrequency.weekly => context.l10n.repeatWeekly, + RecurrenceFrequency.monthly => context.l10n.repeatMonthly, + RecurrenceFrequency.yearly => context.l10n.repeatYearly, + }; diff --git a/lib/src/features/sync/sync_auth_error.dart b/lib/src/features/sync/sync_auth_error.dart index d35639cb..691f75f3 100644 --- a/lib/src/features/sync/sync_auth_error.dart +++ b/lib/src/features/sync/sync_auth_error.dart @@ -1,5 +1,6 @@ import '../../core/logging/redacting_logger.dart'; import 'package:busymax/src/core/auth/oauth_models.dart'; +import 'package:flutter/services.dart'; import '../connectivity/network_connectivity_service.dart'; const accountReconnectRequiredSyncMessage = @@ -7,9 +8,11 @@ const accountReconnectRequiredSyncMessage = const accountReconnectRequiredActionLabel = 'Reconnect this account'; bool isMissingOAuthTokenError(Object error) { - return error is OAuthException && - (error.code == 'OAuthMissingToken' || - error.code == 'MicrosoftOAuthMissingToken'); + return (error is OAuthException && + (error.code == 'OAuthMissingToken' || + error.code == 'MicrosoftOAuthMissingToken')) || + (error is PlatformException && + error.code == 'android/auth-interaction-required'); } String syncFailureMessage( diff --git a/packages/busymax_android_platform/android/src/main/kotlin/io/busystack/busymax_android_platform/BusymaxAndroidPlatformPlugin.kt b/packages/busymax_android_platform/android/src/main/kotlin/io/busystack/busymax_android_platform/BusymaxAndroidPlatformPlugin.kt index 39f7883f..ee5337c8 100644 --- a/packages/busymax_android_platform/android/src/main/kotlin/io/busystack/busymax_android_platform/BusymaxAndroidPlatformPlugin.kt +++ b/packages/busymax_android_platform/android/src/main/kotlin/io/busystack/busymax_android_platform/BusymaxAndroidPlatformPlugin.kt @@ -49,6 +49,44 @@ import java.util.concurrent.Executors import java.util.concurrent.Semaphore import java.util.concurrent.TimeUnit +internal object EngineOwnedAccountGateRegistry { + internal data class Lease(val ownerId: String, val semaphore: Semaphore) + + private val accountGates = ConcurrentHashMap() + private val leases = ConcurrentHashMap() + + fun acquire(accountId: String, ownerId: String, timeoutMillis: Long): String? { + val semaphore = accountGates.computeIfAbsent(accountId) { Semaphore(1, true) } + if (!semaphore.tryAcquire(timeoutMillis, TimeUnit.MILLISECONDS)) return null + val leaseId = UUID.randomUUID().toString() + leases[leaseId] = Lease(ownerId, semaphore) + return leaseId + } + + fun release(leaseId: String?, ownerId: String): Boolean { + if (leaseId == null) return false + var released = false + leases.computeIfPresent(leaseId) { _, lease -> + if (lease.ownerId == ownerId) { + lease.semaphore.release() + released = true + null + } else { + lease + } + } + return released + } + + fun releaseOwned(ownerId: String) { + for ((leaseId, lease) in leases.entries) { + if (lease.ownerId == ownerId && leases.remove(leaseId, lease)) { + lease.semaphore.release() + } + } + } +} + /** BusyMax-owned Android integration. Provider refresh tokens never enter Dart. */ class BusymaxAndroidPlatformPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, @@ -69,6 +107,8 @@ class BusymaxAndroidPlatformPlugin : FlutterPlugin, private var pendingInteractive: PendingInteractive? = null private var pendingDocument: PendingDocument? = null private var pendingPermission: MethodChannel.Result? = null + private val engineId = UUID.randomUUID().toString() + @Volatile private var detached = false @Volatile private var msal: IMultipleAccountPublicClientApplication? = null private var initialActivation: Map? = null @@ -79,6 +119,7 @@ class BusymaxAndroidPlatformPlugin : FlutterPlugin, } override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) { + detached = false context = binding.applicationContext channel = MethodChannel(binding.binaryMessenger, METHOD_CHANNEL) eventChannel = EventChannel(binding.binaryMessenger, EVENT_CHANNEL) @@ -94,6 +135,8 @@ class BusymaxAndroidPlatformPlugin : FlutterPlugin, } override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) { + detached = true + EngineOwnedAccountGateRegistry.releaseOwned(engineId) activePlugins.remove(this) channel.setMethodCallHandler(null) eventChannel.setStreamHandler(null) @@ -159,6 +202,10 @@ class BusymaxAndroidPlatformPlugin : FlutterPlugin, } "acquireAccountGate" -> acquireAccountGate(call, result) "releaseAccountGate" -> releaseAccountGate(call, result) + "releaseOwnedAccountGates" -> { + EngineOwnedAccountGateRegistry.releaseOwned(engineId) + result.success(null) + } "notifyDataChanged" -> { activePlugins.forEach { plugin -> plugin.eventSink?.success(mapOf("kind" to "dataChanged")) @@ -221,7 +268,14 @@ class BusymaxAndroidPlatformPlugin : FlutterPlugin, result.error("android/google-result-invalid", "Google returned an incomplete authorization result.", null) return } - result.success(mapOf("accessToken" to token, "scopes" to authorization.grantedScopes, "nativeAccountId" to nativeId, "username" to account?.email)) + result.success(mapOf( + "accessToken" to token, + // AuthorizationResult exposes the concrete granted scopes as + // Scope values; Scope.toString() is its canonical URI. + "scopes" to authorization.grantedScopes.map { it.toString() }, + "nativeAccountId" to nativeId, + "username" to account?.email, + )) } private fun authorizeMicrosoft(call: MethodCall, result: MethodChannel.Result, interactive: Boolean) { @@ -389,13 +443,17 @@ class BusymaxAndroidPlatformPlugin : FlutterPlugin, pendingDocument = null if (resultCode != Activity.RESULT_OK || data?.data == null) { pending.result.success(null); return true } val uri = data.data!! - try { - when (pending) { - is PendingDocument.Open -> finishOpenDocument(uri, pending) - is PendingDocument.Create -> finishCreateDocument(uri, pending) - is PendingDocument.Tree -> finishTreeExport(uri, pending) + executor.execute { + try { + when (pending) { + is PendingDocument.Open -> finishOpenDocument(uri, pending) + is PendingDocument.Create -> finishCreateDocument(uri, pending) + is PendingDocument.Tree -> finishTreeExport(uri, pending) + } + } catch (error: Exception) { + postError(pending.result, "android/document-failed", "The selected document could not be processed.", error) } - } catch (error: Exception) { pending.result.error("android/document-failed", "The selected document could not be processed.", safeError(error)) } + } return true } @@ -415,15 +473,17 @@ class BusymaxAndroidPlatformPlugin : FlutterPlugin, private fun finishOpenDocument(uri: Uri, pending: PendingDocument.Open) { tryPersistPermission(uri, Intent.FLAG_GRANT_READ_URI_PERMISSION) - pending.result.success(readDocument(uri, pending.maximumBytes)) + postSuccess(pending.result, readDocument(uri, pending.maximumBytes)) } private fun readDocumentUri(call: MethodCall, result: MethodChannel.Result) { val uri = Uri.parse(call.argument("uri").orEmpty()) if (uri.scheme != "content") return result.error("android/invalid-document-uri", "Only content document URIs are supported.", null) val max = (call.argument("maximumBytes")?.toLong() ?: MAX_IMPORT_BYTES).coerceIn(1, MAX_IMPORT_BYTES) - try { result.success(readDocument(uri, max)) } - catch (error: Exception) { result.error("android/document-failed", "The shared document could not be read.", safeError(error)) } + executor.execute { + try { postSuccess(result, readDocument(uri, max)) } + catch (error: Exception) { postError(result, "android/document-failed", "The shared document could not be read.", error) } + } } private fun readDocument(uri: Uri, maximumBytes: Long): Map { @@ -454,7 +514,7 @@ class BusymaxAndroidPlatformPlugin : FlutterPlugin, tryPersistPermission(uri, Intent.FLAG_GRANT_WRITE_URI_PERMISSION) context.contentResolver.openOutputStream(uri, "wt")?.use { it.write(pending.bytes) } ?: throw IllegalArgumentException("Document could not be created.") - pending.result.success(uri.toString()) + postSuccess(pending.result, uri.toString()) } private fun exportDocumentTree(call: MethodCall, result: MethodChannel.Result) { @@ -488,7 +548,7 @@ class BusymaxAndroidPlatformPlugin : FlutterPlugin, resolver.openOutputStream(output, "wt")?.use { it.write(resource.bytes) } ?: throw IllegalStateException("Could not write ${resource.name}.") } - pending.result.success(folder.toString()) + postSuccess(pending.result, folder.toString()) } private fun launchExternalUri(call: MethodCall, result: MethodChannel.Result) { @@ -519,17 +579,24 @@ class BusymaxAndroidPlatformPlugin : FlutterPlugin, val timeout = (call.argument("timeoutMillis")?.toLong() ?: 30_000L).coerceIn(1_000L, 120_000L) if (accountId.isBlank()) return result.error("android/invalid-arguments", "Account id is required.", null) executor.execute { - val semaphore = accountGates.computeIfAbsent(accountId) { Semaphore(1, true) } try { - if (!semaphore.tryAcquire(timeout, TimeUnit.MILLISECONDS)) return@execute postError(result, "android/account-busy", "This account is already synchronizing.", null) - val lease = UUID.randomUUID().toString(); leases[lease] = semaphore; postSuccess(result, lease) + val lease = EngineOwnedAccountGateRegistry.acquire(accountId, engineId, timeout) + ?: return@execute postError(result, "android/account-busy", "This account is already synchronizing.", null) + if (detached) { + EngineOwnedAccountGateRegistry.release(lease, engineId) + return@execute + } + postSuccess(result, lease) } catch (error: InterruptedException) { Thread.currentThread().interrupt(); postError(result, "android/account-gate-interrupted", "Account synchronization was interrupted.", error) } } } - private fun releaseAccountGate(call: MethodCall, result: MethodChannel.Result) { leases.remove(call.argument("leaseId"))?.release(); result.success(null) } + private fun releaseAccountGate(call: MethodCall, result: MethodChannel.Result) { + EngineOwnedAccountGateRegistry.release(call.argument("leaseId"), engineId) + result.success(null) + } override fun onNewIntent(intent: Intent): Boolean = captureActivation(intent) private fun captureActivation(intent: Intent?): Boolean { if (intent == null) return false @@ -590,8 +657,6 @@ class BusymaxAndroidPlatformPlugin : FlutterPlugin, private const val MAX_EXPORT_BYTES = 32 * 1024 * 1024 private const val MAX_TREE_EXPORT_BYTES = 64L * 1024L * 1024L private const val MAX_EXPORT_RESOURCES = 512 - private val accountGates = ConcurrentHashMap() - private val leases = ConcurrentHashMap() private val activePlugins = ConcurrentHashMap.newKeySet() } } diff --git a/packages/busymax_android_platform/android/src/test/kotlin/io/busystack/busymax_android_platform/BusymaxAndroidPlatformPluginTest.kt b/packages/busymax_android_platform/android/src/test/kotlin/io/busystack/busymax_android_platform/BusymaxAndroidPlatformPluginTest.kt index 34597088..d901a0c5 100644 --- a/packages/busymax_android_platform/android/src/test/kotlin/io/busystack/busymax_android_platform/BusymaxAndroidPlatformPluginTest.kt +++ b/packages/busymax_android_platform/android/src/test/kotlin/io/busystack/busymax_android_platform/BusymaxAndroidPlatformPluginTest.kt @@ -2,6 +2,7 @@ package io.busystack.busymax_android_platform import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodChannel +import org.junit.jupiter.api.Assertions.assertNotNull import org.junit.jupiter.api.Test import org.mockito.Mockito @@ -14,4 +15,16 @@ internal class BusymaxAndroidPlatformPluginTest { plugin.onMethodCall(call, mockResult) Mockito.verify(mockResult).notImplemented() } + + @Test + fun releasingEngineOwnedLeases_unblocksAnotherEngine() { + val first = EngineOwnedAccountGateRegistry.acquire("account", "engine-1", 1) + assertNotNull(first) + + EngineOwnedAccountGateRegistry.releaseOwned("engine-1") + + val second = EngineOwnedAccountGateRegistry.acquire("account", "engine-2", 1) + assertNotNull(second) + EngineOwnedAccountGateRegistry.releaseOwned("engine-2") + } } diff --git a/packages/busymax_android_platform/lib/busymax_android_platform.dart b/packages/busymax_android_platform/lib/busymax_android_platform.dart index 8f4bae6a..b11deac2 100644 --- a/packages/busymax_android_platform/lib/busymax_android_platform.dart +++ b/packages/busymax_android_platform/lib/busymax_android_platform.dart @@ -195,6 +195,13 @@ final class BusyMaxAndroidPlatform { Future releaseAccountGate(String leaseId) => _methodChannel .invokeMethod('releaseAccountGate', {'leaseId': leaseId}); + /// Releases every gate acquired by this Flutter engine. + /// + /// WorkManager calls this before tearing down a stopped background engine; + /// the native plugin also performs the same cleanup when detached. + Future releaseOwnedAccountGates() => + _methodChannel.invokeMethod('releaseOwnedAccountGates'); + Future takeInitialActivation() async { final value = await _methodChannel.invokeMethod( 'takeInitialActivation', diff --git a/test/platform/android/android_adaptive_app_test.dart b/test/platform/android/android_adaptive_app_test.dart index d513a773..e9fcf89a 100644 --- a/test/platform/android/android_adaptive_app_test.dart +++ b/test/platform/android/android_adaptive_app_test.dart @@ -1,9 +1,18 @@ import 'package:busymax/src/android/android_app.dart'; import 'package:busymax/src/android/android_notifications.dart'; +import 'package:busymax/src/android/presentation/android_schedule_screen.dart'; +import 'package:busymax/src/android/presentation/android_tasks_screen.dart'; import 'package:busymax/src/app/app_bootstrap.dart'; import 'package:busymax/src/config/build_config.dart'; import 'package:busymax/src/db/app_database.dart'; +import 'package:busymax/src/features/calendar/data/calendar_repository.dart'; +import 'package:busymax/src/features/calendar/presentation/event_editor_draft.dart'; import 'package:busymax/src/features/notifications/notification_reconciler.dart'; +import 'package:busymax/src/features/tasks/data/tasks_repository.dart'; +import 'package:busymax/src/providers/busy_provider.dart'; +import 'package:busymax/src/schedule/schedule_repository.dart'; +import 'package:busymax/src/schedule/schedule_view_mode.dart'; +import 'package:drift/drift.dart' hide isNull; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -16,6 +25,9 @@ void main() { const platformChannel = MethodChannel('io.busystack.busymax/android'); const eventChannel = MethodChannel('io.busystack.busymax/events'); + const secureStorageChannel = MethodChannel( + 'plugins.it_nomads.com/flutter_secure_storage', + ); final messenger = TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; @@ -29,11 +41,13 @@ void main() { }; }); messenger.setMockMethodCallHandler(eventChannel, (_) async => null); + messenger.setMockMethodCallHandler(secureStorageChannel, (_) async => null); }); tearDown(() { messenger.setMockMethodCallHandler(platformChannel, null); messenger.setMockMethodCallHandler(eventChannel, null); + messenger.setMockMethodCallHandler(secureStorageChannel, null); }); testWidgets( @@ -145,13 +159,229 @@ void main() { await harness.dispose(); } }); + + testWidgets('populated Android calendar modes render real event data', ( + tester, + ) async { + tester.view.physicalSize = const Size(412, 915); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + final settings = AppSettings.defaults().copyWith( + androidScheduleViewMode: ScheduleViewMode.day, + taskListScheduleVisibility: const { + 'google:populated::hidden-list': false, + }, + ); + final harness = await _pumpApp(tester, settings, populated: true); + addTearDown(harness.dispose); + + expect(find.byKey(const ValueKey('android-day-time-grid')), findsOneWidget); + expect(find.textContaining('Overnight event'), findsWidgets); + expect(find.text('Hidden event'), findsNothing); + expect(find.text('Hidden task'), findsNothing); + expect(tester.takeException(), isNull); + + await harness.container + .read(appSettingsControllerProvider.notifier) + .setAndroidScheduleViewMode(ScheduleViewMode.month); + await tester.pumpAndSettle(); + expect(find.byKey(const ValueKey('android-month-grid')), findsOneWidget); + expect(find.text('Overnight event'), findsWidgets); + expect(tester.takeException(), isNull); + + await harness.container + .read(appSettingsControllerProvider.notifier) + .setAndroidScheduleViewMode(ScheduleViewMode.year); + await tester.pumpAndSettle(); + expect(find.byKey(const ValueKey('android-year-grid')), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + testWidgets('notification event with no writable source opens read-only', ( + tester, + ) async { + final harness = await _pumpApp(tester, AppSettings.defaults()); + addTearDown(harness.dispose); + final now = DateTime.now(); + + tester + .state(find.byType(Navigator).first) + .push( + MaterialPageRoute( + builder: (_) => AndroidEventEditor( + sources: const [], + draft: EventEditorDraft.existing( + eventId: 'read-only-event', + accountId: 'webcal:read-only', + sourceId: 'read-only-source', + providerCalendarId: 'read-only-source', + title: 'Read-only event', + allDay: false, + start: now, + end: now.add(const Duration(hours: 1)), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + final save = tester.widget( + find.widgetWithText(TextButton, 'Save'), + ); + expect(save.onPressed, isNull); + expect(find.text('Read-only event'), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + testWidgets('read-only task disables save and omits delete', (tester) async { + final harness = await _pumpApp(tester, AppSettings.defaults()); + addTearDown(harness.dispose); + const task = TaskEntity( + accountId: 'webcal:read-only', + taskListId: 'read-only-list', + id: 'read-only-task', + title: 'Read-only task', + localDirty: false, + pendingDelete: false, + pendingMove: false, + rawJson: '{}', + updatedLocalAtUtc: '2026-09-14T00:00:00.000Z', + ); + + tester + .state(find.byType(Navigator).first) + .push( + MaterialPageRoute( + builder: (_) => const AndroidTaskEditor( + accountId: 'webcal:read-only', + provider: BusyProvider.webCal, + task: task, + accountLabel: 'Read-only account', + listLabel: 'Read-only list', + ), + ), + ); + await tester.pumpAndSettle(); + + final save = tester.widget( + find.widgetWithText(TextButton, 'Save'), + ); + expect(save.onPressed, isNull); + expect(find.text('Delete task'), findsNothing); + expect(find.text('Read-only account'), findsOneWidget); + expect(find.text('Read-only list'), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + testWidgets('modified Android event requires discard confirmation', ( + tester, + ) async { + final harness = await _pumpApp(tester, AppSettings.defaults()); + addTearDown(harness.dispose); + final now = DateTime.now(); + const source = CalendarSourceEntity( + id: 'calendar', + accountId: 'google:editable', + provider: BusyProvider.google, + providerCalendarId: 'calendar', + summary: 'Editable calendar', + selected: true, + hidden: false, + readOnly: false, + isDeleted: false, + ); + + tester + .state(find.byType(Navigator).first) + .push( + MaterialPageRoute( + builder: (_) => AndroidEventEditor( + sources: const [source], + draft: EventEditorDraft.existing( + eventId: 'editable-event', + accountId: source.accountId, + sourceId: source.id, + providerCalendarId: source.providerCalendarId, + title: 'Original title', + allDay: false, + start: now, + end: now.add(const Duration(hours: 1)), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + await tester.enterText(find.byType(TextFormField).first, 'Changed title'); + await tester.tap(find.byIcon(Icons.close)); + await tester.pumpAndSettle(); + + expect(find.text('Discard changes?'), findsOneWidget); + expect(find.text('Changed title'), findsOneWidget); + await tester.tap(find.text('Discard')); + await tester.pumpAndSettle(); + expect(find.byType(AndroidEventEditor), findsNothing); + expect(tester.takeException(), isNull); + }); + + testWidgets('modified Android task requires discard confirmation', ( + tester, + ) async { + final harness = await _pumpApp(tester, AppSettings.defaults()); + addTearDown(harness.dispose); + const task = TaskEntity( + accountId: 'google:editable', + taskListId: 'editable-list', + id: 'editable-task', + title: 'Original task', + localDirty: false, + pendingDelete: false, + pendingMove: false, + rawJson: '{}', + updatedLocalAtUtc: '2026-09-14T00:00:00.000Z', + ); + + tester + .state(find.byType(Navigator).first) + .push( + MaterialPageRoute( + builder: (_) => const AndroidTaskEditor( + accountId: 'google:editable', + provider: BusyProvider.google, + task: task, + accountLabel: 'Editable account', + listLabel: 'Editable list', + ), + ), + ); + await tester.pumpAndSettle(); + + final editorTextFields = find.descendant( + of: find.byType(AndroidTaskEditor), + matching: find.byType(TextField), + ); + await tester.enterText(editorTextFields.first, 'Changed task'); + await tester.tap(find.byIcon(Icons.close)); + await tester.pumpAndSettle(); + + expect(find.text('Discard changes?'), findsOneWidget); + expect(find.text('Changed task'), findsOneWidget); + await tester.tap(find.text('Discard')); + await tester.pumpAndSettle(); + expect(find.byType(AndroidTaskEditor), findsNothing); + expect(tester.takeException(), isNull); + }); } Future<_AndroidAppHarness> _pumpApp( WidgetTester tester, - AppSettings settings, -) async { + AppSettings settings, { + bool populated = false, +}) async { final database = AppDatabase.memoryForTests(); + if (populated) await _seedPopulatedSchedule(database); final notifications = AndroidNotificationService( database: database, settings: () => settings, @@ -160,6 +390,9 @@ Future<_AndroidAppHarness> _pumpApp( overrides: [ buildConfigProvider.overrideWithValue(BuildConfig.forAndroid()), databaseProvider.overrideWithValue(database), + scheduleRepositoryProvider.overrideWithValue( + ScheduleRepository(database), + ), initialAppSettingsProvider.overrideWithValue(settings), localSettingsStoreProvider.overrideWithValue(MemorySettingsStore()), allAccountsSyncRunnerProvider.overrideWithValue(() async {}), @@ -179,6 +412,122 @@ Future<_AndroidAppHarness> _pumpApp( return _AndroidAppHarness(tester, container, database); } +Future _seedPopulatedSchedule(AppDatabase database) async { + final today = DateTime.now(); + final start = DateTime(today.year, today.month, today.day - 1, 23); + final end = DateTime(today.year, today.month, today.day, 1); + const timestamp = '2026-09-14T00:00:00.000Z'; + await database + .into(database.accounts) + .insert( + AccountsCompanion.insert( + id: 'google:populated', + provider: 'google', + authority: 'https://accounts.google.com', + providerAccountId: 'populated', + credentialKind: 'oauth', + authState: const Value('signed_in'), + displayName: const Value('Calendar Account'), + createdAtUtc: timestamp, + updatedAtUtc: timestamp, + ), + ); + await database + .into(database.calendarSources) + .insert( + CalendarSourcesCompanion.insert( + id: 'calendar', + accountId: 'google:populated', + provider: 'google', + providerCalendarId: 'calendar', + summary: 'Populated calendar', + selected: const Value(true), + createdAtLocal: 1, + updatedAtLocal: 1, + ), + ); + await database + .into(database.calendarEvents) + .insert( + CalendarEventsCompanion.insert( + id: 'overnight', + accountId: 'google:populated', + calendarSourceId: 'calendar', + provider: 'google', + providerCalendarId: 'calendar', + providerEventId: 'overnight', + title: 'Overnight event', + startDateTime: Value(start.toIso8601String()), + endDateTime: Value(end.toIso8601String()), + rawJson: const Value('{}'), + createdAtLocal: 1, + updatedAtLocal: 1, + ), + ); + await database + .into(database.calendarSources) + .insert( + CalendarSourcesCompanion.insert( + id: 'hidden-calendar', + accountId: 'google:populated', + provider: 'google', + providerCalendarId: 'hidden-calendar', + summary: 'Hidden calendar', + selected: const Value(false), + createdAtLocal: 1, + updatedAtLocal: 1, + ), + ); + await database + .into(database.calendarEvents) + .insert( + CalendarEventsCompanion.insert( + id: 'hidden-event', + accountId: 'google:populated', + calendarSourceId: 'hidden-calendar', + provider: 'google', + providerCalendarId: 'hidden-calendar', + providerEventId: 'hidden-event', + title: 'Hidden event', + startDateTime: Value(start.toIso8601String()), + endDateTime: Value(end.toIso8601String()), + rawJson: const Value('{}'), + createdAtLocal: 1, + updatedAtLocal: 1, + ), + ); + await database.taskListsDao.upsertTaskList( + TaskListsCompanion.insert( + accountId: 'google:populated', + id: 'hidden-list', + title: 'Hidden tasks', + rawJson: '{}', + createdLocalAtUtc: timestamp, + updatedLocalAtUtc: timestamp, + ), + ); + await database.tasksDao.upsertTask( + TasksCompanion.insert( + accountId: 'google:populated', + taskListId: 'hidden-list', + id: 'hidden-task', + title: 'Hidden task', + dueUtc: Value( + DateTime( + today.year, + today.month, + today.day, + 12, + ).toUtc().toIso8601String(), + ), + status: const Value('needsAction'), + rawJson: '{}', + createdLocalAtUtc: timestamp, + updatedLocalAtUtc: timestamp, + ), + ); +} + final class _AndroidAppHarness { const _AndroidAppHarness(this.tester, this.container, this.database); diff --git a/test/platform/android/android_authorization_broker_test.dart b/test/platform/android/android_authorization_broker_test.dart new file mode 100644 index 00000000..1e0afde7 --- /dev/null +++ b/test/platform/android/android_authorization_broker_test.dart @@ -0,0 +1,107 @@ +import 'package:busymax/src/android/android_authorization.dart'; +import 'package:busymax/src/config/build_config.dart'; +import 'package:busymax/src/core/auth/oauth_models.dart'; +import 'package:busymax/src/core/secrets/secret_store.dart'; +import 'package:busymax/src/google_tasks/api/google_tasks_api_surface.dart'; +import 'package:busymax/src/features/sync/sync_auth_error.dart'; +import 'package:busymax/src/providers/busy_provider.dart'; +import 'package:busymax_android_platform/busymax_android_platform.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/testing.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + const channel = MethodChannel('busymax.test.authorization'); + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + + tearDown(() => messenger.setMockMethodCallHandler(channel, null)); + + AndroidAuthorizationBroker broker(InMemorySecretStore store) => + AndroidAuthorizationBroker( + platform: BusyMaxAndroidPlatform(methodChannel: channel), + httpClient: MockClient((_) async => throw UnimplementedError()), + secretStore: store, + config: BuildConfig.forAndroid(), + ); + + test( + 'silent Google authorization does not invent requested grants', + () async { + messenger.setMockMethodCallHandler(channel, (call) async { + expect(call.method, 'authorizeGoogleSilent'); + return { + 'accessToken': 'token', + 'nativeAccountId': 'native', + 'scopes': [googleTasksReadWriteScope], + }; + }); + + await expectLater( + broker( + InMemorySecretStore(), + ).authorizationHeader(BusyProvider.google, 'google:account'), + throwsA( + isA().having( + (error) => error.code, + 'code', + 'OAuthMissingToken', + ), + ), + ); + }, + ); + + test('sync classifier defensively recognizes native reconnect errors', () { + expect( + isMissingOAuthTokenError( + PlatformException(code: 'android/auth-interaction-required'), + ), + isTrue, + ); + }); + + test( + 'interaction-required native error uses reconnect domain error', + () async { + messenger.setMockMethodCallHandler(channel, (call) async { + throw PlatformException( + code: 'android/auth-interaction-required', + message: 'Native detail', + ); + }); + + await expectLater( + broker( + InMemorySecretStore(), + ).authorizationHeader(BusyProvider.microsoft, 'microsoft:account'), + throwsA( + isA().having( + (error) => error.code, + 'code', + 'MicrosoftOAuthMissingToken', + ), + ), + ); + }, + ); + + test('local Google removal clears the native account binding', () async { + final calls = []; + messenger.setMockMethodCallHandler(channel, (call) async { + calls.add(call); + return null; + }); + final store = InMemorySecretStore(); + await store.setActiveAccountId('google:account'); + + await broker(store).clearLocalSession(accountId: 'google:account'); + + final removal = calls.singleWhere( + (call) => call.method == 'removeAuthorization', + ); + expect(removal.arguments, containsPair('accountId', 'google:account')); + expect(removal.arguments, containsPair('revoke', false)); + }); +} diff --git a/test/platform/android/android_event_attendee_edit_test.dart b/test/platform/android/android_event_attendee_edit_test.dart new file mode 100644 index 00000000..0f694e25 --- /dev/null +++ b/test/platform/android/android_event_attendee_edit_test.dart @@ -0,0 +1,51 @@ +import 'package:busymax/src/android/presentation/android_schedule_screen.dart'; +import 'package:busymax/src/features/calendar/presentation/event_editor_draft.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test( + 'unchanged guest text preserves structured attendees and change flag', + () { + const attendees = [ + EventAttendeeDraft( + email: 'owner@example.test', + displayName: 'Owner', + self: true, + organizer: true, + responseStatus: 'accepted', + ), + EventAttendeeDraft( + email: 'guest@example.test', + displayName: 'Optional Guest', + optional: true, + responseStatus: 'tentative', + ), + ]; + + final edit = mergeAndroidEventAttendees(attendees, 'guest@example.test'); + + expect(edit.changed, isFalse); + expect(identical(edit.attendees, attendees), isTrue); + expect(edit.attendees.last.optional, isTrue); + expect(edit.attendees.last.displayName, 'Optional Guest'); + expect(edit.attendees.last.responseStatus, 'tentative'); + }, + ); + + test('guest additions preserve metadata on existing guests', () { + const existing = EventAttendeeDraft( + email: 'guest@example.test', + displayName: 'Guest', + optional: true, + responseStatus: 'accepted', + ); + + final edit = mergeAndroidEventAttendees(const [ + existing, + ], 'guest@example.test, new@example.test'); + + expect(edit.changed, isTrue); + expect(identical(edit.attendees.first, existing), isTrue); + expect(edit.attendees.last.email, 'new@example.test'); + }); +} diff --git a/test/platform/android/android_local_network_host_test.dart b/test/platform/android/android_local_network_host_test.dart new file mode 100644 index 00000000..e5a29006 --- /dev/null +++ b/test/platform/android/android_local_network_host_test.dart @@ -0,0 +1,32 @@ +import 'package:busymax/src/android/presentation/android_settings_screen.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('recognizes local DNS names and private IPv4 ranges', () { + for (final host in [ + 'localhost', + 'nextcloud', + 'cloud.local', + 'cloud.lan', + '10.0.0.4', + '172.31.2.3', + '192.168.1.9', + '127.0.0.1', + '169.254.10.20', + ]) { + expect(isAndroidLocalNetworkHost(host), isTrue, reason: host); + } + }); + + test('recognizes private and link-local IPv6 ranges', () { + expect(isAndroidLocalNetworkHost('::1'), isTrue); + expect(isAndroidLocalNetworkHost('fd12:3456::1'), isTrue); + expect(isAndroidLocalNetworkHost('fe80::a'), isTrue); + }); + + test('does not classify public hosts and addresses as local', () { + expect(isAndroidLocalNetworkHost('cloud.example.com'), isFalse); + expect(isAndroidLocalNetworkHost('8.8.8.8'), isFalse); + expect(isAndroidLocalNetworkHost('2001:4860:4860::8888'), isFalse); + }); +} diff --git a/test/platform/android/android_notification_policy_test.dart b/test/platform/android/android_notification_policy_test.dart index befad0cb..0ab0d0e3 100644 --- a/test/platform/android/android_notification_policy_test.dart +++ b/test/platform/android/android_notification_policy_test.dart @@ -39,6 +39,71 @@ void main() { ); }); + test('quiet-hour deferral remains eligible after its original time', () { + final now = DateTime.utc(2026, 1, 1, 23, 15).millisecondsSinceEpoch; + final deferred = DateTime.utc(2026, 1, 2, 7).millisecondsSinceEpoch; + + expect( + shouldKeepAndroidReminder( + effectiveAt: deferred, + now: now, + horizon: DateTime.utc(2026, 4, 1).millisecondsSinceEpoch, + remainsPending: false, + ), + isTrue, + ); + }); + + test('an overdue inexact registration is retained while Android has it', () { + expect( + shouldKeepAndroidReminder( + effectiveAt: 100, + now: 200, + horizon: 1000, + remainsPending: true, + ), + isTrue, + ); + expect( + shouldKeepAndroidReminder( + effectiveAt: 100, + now: 200, + horizon: 1000, + remainsPending: false, + ), + isFalse, + ); + }); + + test('registration fingerprint changes for privacy and alarm precision', () { + final normal = AppSettings.defaults(); + final private = normal.copyWith( + notificationDetailLevel: NotificationDetailLevel.private, + ); + + expect( + androidNotificationRegistrationState(settings: normal, exact: false), + isNot( + androidNotificationRegistrationState(settings: private, exact: false), + ), + ); + expect( + androidNotificationRegistrationState(settings: normal, exact: false), + isNot( + androidNotificationRegistrationState(settings: normal, exact: true), + ), + ); + }); + + test('explicit Open action launches the user interface', () { + final action = androidOpenNotificationAction( + const AndroidNotificationStrings(), + ); + + expect(action.id, androidNotificationActionOpen); + expect(action.showsUserInterface, isTrue); + }); + test('activation payload parsing rejects incomplete data', () { expect(parseAndroidReminderActivation('{"schedule":"only"}'), isNull); final activation = parseAndroidReminderActivation( diff --git a/test/platform/android/android_platform_channel_test.dart b/test/platform/android/android_platform_channel_test.dart index f5b26d5d..62c93a2c 100644 --- a/test/platform/android/android_platform_channel_test.dart +++ b/test/platform/android/android_platform_channel_test.dart @@ -78,4 +78,12 @@ void main() { expect(calls.single.arguments, containsPair('maximumBytes', 1024)); }, ); + + test('worker teardown releases every gate owned by its engine', () async { + final platform = BusyMaxAndroidPlatform(methodChannel: channel); + + await platform.releaseOwnedAccountGates(); + + expect(calls.single.method, 'releaseOwnedAccountGates'); + }); } From b0f816ca4687decd4b502d0f0fdf6ac518de40cc Mon Sep 17 00:00:00 2001 From: albert Date: Mon, 14 Sep 2026 17:11:39 -0700 Subject: [PATCH 04/25] Fixed Launch at login: Disabled GNOME startup entry, preserving a recoverable. Updated the code to explain externally managed Snap startup and correctly disable inherited system-wide Linux entries. --- lib/l10n/app_ar.arb | 1 + lib/l10n/app_de.arb | 1 + lib/l10n/app_en.arb | 1 + lib/l10n/app_es.arb | 1 + lib/l10n/app_et.arb | 1 + lib/l10n/app_fa.arb | 1 + lib/l10n/app_fi.arb | 1 + lib/l10n/app_fr.arb | 1 + lib/l10n/app_hi.arb | 1 + lib/l10n/app_it.arb | 1 + lib/l10n/app_ja.arb | 1 + lib/l10n/app_ko.arb | 1 + lib/l10n/app_pl.arb | 1 + lib/l10n/app_pt_PT.arb | 1 + lib/l10n/app_ru.arb | 1 + lib/l10n/app_vi.arb | 1 + lib/l10n/app_zh.arb | 1 + lib/l10n/app_zh_Hans.arb | 1 + lib/l10n/app_zh_Hant.arb | 1 + lib/l10n/generated/app_localizations.dart | 6 + lib/l10n/generated/app_localizations_ar.dart | 4 + lib/l10n/generated/app_localizations_de.dart | 4 + lib/l10n/generated/app_localizations_en.dart | 4 + lib/l10n/generated/app_localizations_es.dart | 4 + lib/l10n/generated/app_localizations_et.dart | 4 + lib/l10n/generated/app_localizations_fa.dart | 4 + lib/l10n/generated/app_localizations_fi.dart | 4 + lib/l10n/generated/app_localizations_fr.dart | 4 + lib/l10n/generated/app_localizations_hi.dart | 4 + lib/l10n/generated/app_localizations_it.dart | 4 + lib/l10n/generated/app_localizations_ja.dart | 4 + lib/l10n/generated/app_localizations_ko.dart | 4 + lib/l10n/generated/app_localizations_pl.dart | 4 + lib/l10n/generated/app_localizations_pt.dart | 8 + lib/l10n/generated/app_localizations_ru.dart | 4 + lib/l10n/generated/app_localizations_vi.dart | 4 + lib/l10n/generated/app_localizations_zh.dart | 12 + .../presentation/settings_screen.dart | 3 + lib/src/platform/common/desktop_services.dart | 6 +- lib/src/platform/linux_autostart_service.dart | 91 ++++++-- .../presentation/settings_screen_test.dart | 24 ++ .../linux_autostart_service_test.dart | 205 ++++++++++++++++++ .../windows_autostart_service_test.dart | 4 +- 43 files changed, 423 insertions(+), 15 deletions(-) diff --git a/lib/l10n/app_ar.arb b/lib/l10n/app_ar.arb index cc071677..d9aec457 100644 --- a/lib/l10n/app_ar.arb +++ b/lib/l10n/app_ar.arb @@ -1,4 +1,5 @@ { + "launchAtLoginManagedExternally": "تم تشغيل BusyMax من إعدادات بدء التشغيل في سطح المكتب. أزل إدخاله هناك، ثم أنهِ BusyMax وافتحه مجددًا لاستخدام هذا المفتاح.", "timeFormat": "تنسيق الوقت", "timeFormatTwelveHour": "12 ساعة", "timeFormatTwentyFourHour": "24 ساعة", diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index dcc96092..5d4a5fb9 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -1,4 +1,5 @@ { + "launchAtLoginManagedExternally": "BusyMax wurde durch die Autostart-Einstellungen Ihres Desktops gestartet. Entfernen Sie dort den Eintrag, beenden Sie BusyMax und öffnen Sie es erneut, um diesen Schalter zu verwenden.", "timeFormat": "Zeitformat", "timeFormatTwelveHour": "12 Stunden", "timeFormatTwentyFourHour": "24 Stunden", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 74d16664..00cc5f45 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1,4 +1,5 @@ { + "launchAtLoginManagedExternally": "BusyMax was launched by your desktop's startup settings. Remove its entry there, then quit and reopen BusyMax to use this switch.", "timeFormat": "Time format", "timeFormatTwelveHour": "12-hour", "timeFormatTwentyFourHour": "24-hour", diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index faaec07f..9d57c373 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -1,4 +1,5 @@ { + "launchAtLoginManagedExternally": "BusyMax se inició desde los ajustes de inicio del escritorio. Elimina su entrada allí y luego sal de BusyMax y vuelve a abrirlo para usar este interruptor.", "timeFormat": "Formato de hora", "timeFormatTwelveHour": "12 horas", "timeFormatTwentyFourHour": "24 horas", diff --git a/lib/l10n/app_et.arb b/lib/l10n/app_et.arb index 5e37c343..17ca8c45 100644 --- a/lib/l10n/app_et.arb +++ b/lib/l10n/app_et.arb @@ -1,4 +1,5 @@ { + "launchAtLoginManagedExternally": "BusyMax käivitati töölaua automaatkäivituse seadete kaudu. Eemalda sealt selle kirje, seejärel sulge BusyMax täielikult ja ava see lüliti kasutamiseks uuesti.", "timeFormat": "Kellaajavorming", "timeFormatTwelveHour": "12-tunnine", "timeFormatTwentyFourHour": "24-tunnine", diff --git a/lib/l10n/app_fa.arb b/lib/l10n/app_fa.arb index c35fee64..6337b32b 100644 --- a/lib/l10n/app_fa.arb +++ b/lib/l10n/app_fa.arb @@ -1,4 +1,5 @@ { + "launchAtLoginManagedExternally": "BusyMax از تنظیمات راه‌اندازی خودکار میزکار شما اجرا شده است. ورودی آن را در آنجا حذف کنید، سپس از BusyMax خارج شوید و دوباره آن را باز کنید تا بتوانید از این کلید استفاده کنید.", "timeFormat": "قالب زمان", "timeFormatTwelveHour": "۱۲ ساعته", "timeFormatTwentyFourHour": "۲۴ ساعته", diff --git a/lib/l10n/app_fi.arb b/lib/l10n/app_fi.arb index a3cb1bdc..e8055798 100644 --- a/lib/l10n/app_fi.arb +++ b/lib/l10n/app_fi.arb @@ -1,4 +1,5 @@ { + "launchAtLoginManagedExternally": "BusyMax käynnistettiin työpöydän automaattisen käynnistyksen asetuksista. Poista sen merkintä sieltä, lopeta BusyMax ja avaa se uudelleen, jotta voit käyttää tätä kytkintä.", "timeFormat": "Aikamuoto", "timeFormatTwelveHour": "12 tuntia", "timeFormatTwentyFourHour": "24 tuntia", diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 5046e9b5..23d15a7f 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -1,4 +1,5 @@ { + "launchAtLoginManagedExternally": "BusyMax a été lancé par les paramètres de démarrage de votre bureau. Supprimez son entrée à cet endroit, puis quittez et rouvrez BusyMax pour utiliser cet interrupteur.", "timeFormat": "Format de l’heure", "timeFormatTwelveHour": "12 heures", "timeFormatTwentyFourHour": "24 heures", diff --git a/lib/l10n/app_hi.arb b/lib/l10n/app_hi.arb index 37ebe383..3b3455fe 100644 --- a/lib/l10n/app_hi.arb +++ b/lib/l10n/app_hi.arb @@ -1,4 +1,5 @@ { + "launchAtLoginManagedExternally": "BusyMax आपके डेस्कटॉप की स्टार्टअप सेटिंग से शुरू हुआ था। वहाँ से इसकी प्रविष्टि हटाएँ, फिर इस स्विच का उपयोग करने के लिए BusyMax से बाहर निकलें और इसे दोबारा खोलें।", "timeFormat": "समय प्रारूप", "timeFormatTwelveHour": "12 घंटे", "timeFormatTwentyFourHour": "24 घंटे", diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb index a3024906..e4e6f274 100644 --- a/lib/l10n/app_it.arb +++ b/lib/l10n/app_it.arb @@ -1,4 +1,5 @@ { + "launchAtLoginManagedExternally": "BusyMax è stato avviato dalle impostazioni di avvio del desktop. Rimuovi la sua voce da lì, poi esci da BusyMax e riaprilo per usare questo interruttore.", "timeFormat": "Formato ora", "timeFormatTwelveHour": "12 ore", "timeFormatTwentyFourHour": "24 ore", diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index 1105f9cb..13c78649 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -1,4 +1,5 @@ { + "launchAtLoginManagedExternally": "BusyMax はデスクトップの自動起動設定によって起動されました。このスイッチを使うには、そこで BusyMax の項目を削除してから、BusyMax を終了して開き直してください。", "timeFormat": "時刻の表示形式", "timeFormatTwelveHour": "12時間表示", "timeFormatTwentyFourHour": "24時間表示", diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index 6d867555..cbc1e9c9 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -1,4 +1,5 @@ { + "launchAtLoginManagedExternally": "데스크톱의 시작 프로그램 설정에 의해 BusyMax가 실행되었습니다. 이 스위치를 사용하려면 해당 설정에서 BusyMax 항목을 제거한 다음 BusyMax를 완전히 종료하고 다시 여세요.", "timeFormat": "시간 형식", "timeFormatTwelveHour": "12시간", "timeFormatTwentyFourHour": "24시간", diff --git a/lib/l10n/app_pl.arb b/lib/l10n/app_pl.arb index 262365d6..0ac60c43 100644 --- a/lib/l10n/app_pl.arb +++ b/lib/l10n/app_pl.arb @@ -1,4 +1,5 @@ { + "launchAtLoginManagedExternally": "BusyMax został uruchomiony przez ustawienia autostartu pulpitu. Usuń stamtąd jego wpis, a następnie zakończ i ponownie uruchom BusyMax, aby użyć tego przełącznika.", "timeFormat": "Format czasu", "timeFormatTwelveHour": "12-godzinny", "timeFormatTwentyFourHour": "24-godzinny", diff --git a/lib/l10n/app_pt_PT.arb b/lib/l10n/app_pt_PT.arb index 46eed407..4ca981a3 100644 --- a/lib/l10n/app_pt_PT.arb +++ b/lib/l10n/app_pt_PT.arb @@ -1,4 +1,5 @@ { + "launchAtLoginManagedExternally": "O BusyMax foi iniciado pelas definições de arranque do ambiente de trabalho. Remova a respetiva entrada, depois saia do BusyMax e volte a abri-lo para utilizar este interruptor.", "timeFormat": "Formato da hora", "timeFormatTwelveHour": "12 horas", "timeFormatTwentyFourHour": "24 horas", diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index a4f1a4e2..ac20f242 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -1,4 +1,5 @@ { + "launchAtLoginManagedExternally": "BusyMax запущен через настройки автозапуска рабочего стола. Удалите его запись там, затем полностью завершите и снова откройте BusyMax, чтобы использовать этот переключатель.", "timeFormat": "Формат времени", "timeFormatTwelveHour": "12 часов", "timeFormatTwentyFourHour": "24 часа", diff --git a/lib/l10n/app_vi.arb b/lib/l10n/app_vi.arb index 108340a1..469eb74a 100644 --- a/lib/l10n/app_vi.arb +++ b/lib/l10n/app_vi.arb @@ -1,4 +1,5 @@ { + "launchAtLoginManagedExternally": "BusyMax được mở bởi cài đặt khởi động của môi trường máy tính. Xóa mục BusyMax ở đó, rồi thoát hẳn và mở lại BusyMax để dùng công tắc này.", "timeFormat": "Định dạng giờ", "timeFormatTwelveHour": "12 giờ", "timeFormatTwentyFourHour": "24 giờ", diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index ea8614c3..54553c10 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -1,4 +1,5 @@ { + "launchAtLoginManagedExternally": "BusyMax 由桌面的启动设置启动。请在那里移除 BusyMax 的条目,然后完全退出并重新打开 BusyMax,以使用此开关。", "timeFormat": "时间格式", "timeFormatTwelveHour": "12小时制", "timeFormatTwentyFourHour": "24小时制", diff --git a/lib/l10n/app_zh_Hans.arb b/lib/l10n/app_zh_Hans.arb index 7adbcb39..a9fb3b4a 100644 --- a/lib/l10n/app_zh_Hans.arb +++ b/lib/l10n/app_zh_Hans.arb @@ -1,4 +1,5 @@ { + "launchAtLoginManagedExternally": "BusyMax 由桌面的启动设置启动。请在那里移除 BusyMax 的条目,然后完全退出并重新打开 BusyMax,以使用此开关。", "timeFormat": "时间格式", "timeFormatTwelveHour": "12小时制", "timeFormatTwentyFourHour": "24小时制", diff --git a/lib/l10n/app_zh_Hant.arb b/lib/l10n/app_zh_Hant.arb index 1b16e4fd..04f31556 100644 --- a/lib/l10n/app_zh_Hant.arb +++ b/lib/l10n/app_zh_Hant.arb @@ -1,4 +1,5 @@ { + "launchAtLoginManagedExternally": "BusyMax 由桌面的啟動設定啟動。請在那裡移除 BusyMax 的項目,然後完全結束並重新開啟 BusyMax,以使用此開關。", "timeFormat": "時間格式", "timeFormatTwelveHour": "12小時制", "timeFormatTwentyFourHour": "24小時制", diff --git a/lib/l10n/generated/app_localizations.dart b/lib/l10n/generated/app_localizations.dart index 085a3033..ff8247f1 100644 --- a/lib/l10n/generated/app_localizations.dart +++ b/lib/l10n/generated/app_localizations.dart @@ -135,6 +135,12 @@ abstract class AppLocalizations { Locale.fromSubtags(languageCode: 'zh', scriptCode: 'Hant'), ]; + /// No description provided for @launchAtLoginManagedExternally. + /// + /// In en, this message translates to: + /// **'BusyMax was launched by your desktop\'s startup settings. Remove its entry there, then quit and reopen BusyMax to use this switch.'** + String get launchAtLoginManagedExternally; + /// No description provided for @timeFormat. /// /// In en, this message translates to: diff --git a/lib/l10n/generated/app_localizations_ar.dart b/lib/l10n/generated/app_localizations_ar.dart index 1a12e206..939d7938 100644 --- a/lib/l10n/generated/app_localizations_ar.dart +++ b/lib/l10n/generated/app_localizations_ar.dart @@ -12,6 +12,10 @@ import 'app_localizations.dart'; class AppLocalizationsAr extends AppLocalizations { AppLocalizationsAr([String locale = 'ar']) : super(locale); + @override + String get launchAtLoginManagedExternally => + 'تم تشغيل BusyMax من إعدادات بدء التشغيل في سطح المكتب. أزل إدخاله هناك، ثم أنهِ BusyMax وافتحه مجددًا لاستخدام هذا المفتاح.'; + @override String get timeFormat => 'تنسيق الوقت'; diff --git a/lib/l10n/generated/app_localizations_de.dart b/lib/l10n/generated/app_localizations_de.dart index 343efae8..a96c9d0c 100644 --- a/lib/l10n/generated/app_localizations_de.dart +++ b/lib/l10n/generated/app_localizations_de.dart @@ -12,6 +12,10 @@ import 'app_localizations.dart'; class AppLocalizationsDe extends AppLocalizations { AppLocalizationsDe([String locale = 'de']) : super(locale); + @override + String get launchAtLoginManagedExternally => + 'BusyMax wurde durch die Autostart-Einstellungen Ihres Desktops gestartet. Entfernen Sie dort den Eintrag, beenden Sie BusyMax und öffnen Sie es erneut, um diesen Schalter zu verwenden.'; + @override String get timeFormat => 'Zeitformat'; diff --git a/lib/l10n/generated/app_localizations_en.dart b/lib/l10n/generated/app_localizations_en.dart index aa8cc416..60d03fc6 100644 --- a/lib/l10n/generated/app_localizations_en.dart +++ b/lib/l10n/generated/app_localizations_en.dart @@ -12,6 +12,10 @@ import 'app_localizations.dart'; class AppLocalizationsEn extends AppLocalizations { AppLocalizationsEn([String locale = 'en']) : super(locale); + @override + String get launchAtLoginManagedExternally => + 'BusyMax was launched by your desktop\'s startup settings. Remove its entry there, then quit and reopen BusyMax to use this switch.'; + @override String get timeFormat => 'Time format'; diff --git a/lib/l10n/generated/app_localizations_es.dart b/lib/l10n/generated/app_localizations_es.dart index d46e319d..82241326 100644 --- a/lib/l10n/generated/app_localizations_es.dart +++ b/lib/l10n/generated/app_localizations_es.dart @@ -12,6 +12,10 @@ import 'app_localizations.dart'; class AppLocalizationsEs extends AppLocalizations { AppLocalizationsEs([String locale = 'es']) : super(locale); + @override + String get launchAtLoginManagedExternally => + 'BusyMax se inició desde los ajustes de inicio del escritorio. Elimina su entrada allí y luego sal de BusyMax y vuelve a abrirlo para usar este interruptor.'; + @override String get timeFormat => 'Formato de hora'; diff --git a/lib/l10n/generated/app_localizations_et.dart b/lib/l10n/generated/app_localizations_et.dart index 055d4941..bcfd9e8c 100644 --- a/lib/l10n/generated/app_localizations_et.dart +++ b/lib/l10n/generated/app_localizations_et.dart @@ -12,6 +12,10 @@ import 'app_localizations.dart'; class AppLocalizationsEt extends AppLocalizations { AppLocalizationsEt([String locale = 'et']) : super(locale); + @override + String get launchAtLoginManagedExternally => + 'BusyMax käivitati töölaua automaatkäivituse seadete kaudu. Eemalda sealt selle kirje, seejärel sulge BusyMax täielikult ja ava see lüliti kasutamiseks uuesti.'; + @override String get timeFormat => 'Kellaajavorming'; diff --git a/lib/l10n/generated/app_localizations_fa.dart b/lib/l10n/generated/app_localizations_fa.dart index eaa75e4d..9803f4b9 100644 --- a/lib/l10n/generated/app_localizations_fa.dart +++ b/lib/l10n/generated/app_localizations_fa.dart @@ -12,6 +12,10 @@ import 'app_localizations.dart'; class AppLocalizationsFa extends AppLocalizations { AppLocalizationsFa([String locale = 'fa']) : super(locale); + @override + String get launchAtLoginManagedExternally => + 'BusyMax از تنظیمات راه‌اندازی خودکار میزکار شما اجرا شده است. ورودی آن را در آنجا حذف کنید، سپس از BusyMax خارج شوید و دوباره آن را باز کنید تا بتوانید از این کلید استفاده کنید.'; + @override String get timeFormat => 'قالب زمان'; diff --git a/lib/l10n/generated/app_localizations_fi.dart b/lib/l10n/generated/app_localizations_fi.dart index b86302cd..95253e9a 100644 --- a/lib/l10n/generated/app_localizations_fi.dart +++ b/lib/l10n/generated/app_localizations_fi.dart @@ -12,6 +12,10 @@ import 'app_localizations.dart'; class AppLocalizationsFi extends AppLocalizations { AppLocalizationsFi([String locale = 'fi']) : super(locale); + @override + String get launchAtLoginManagedExternally => + 'BusyMax käynnistettiin työpöydän automaattisen käynnistyksen asetuksista. Poista sen merkintä sieltä, lopeta BusyMax ja avaa se uudelleen, jotta voit käyttää tätä kytkintä.'; + @override String get timeFormat => 'Aikamuoto'; diff --git a/lib/l10n/generated/app_localizations_fr.dart b/lib/l10n/generated/app_localizations_fr.dart index 624906d5..a364fcb4 100644 --- a/lib/l10n/generated/app_localizations_fr.dart +++ b/lib/l10n/generated/app_localizations_fr.dart @@ -12,6 +12,10 @@ import 'app_localizations.dart'; class AppLocalizationsFr extends AppLocalizations { AppLocalizationsFr([String locale = 'fr']) : super(locale); + @override + String get launchAtLoginManagedExternally => + 'BusyMax a été lancé par les paramètres de démarrage de votre bureau. Supprimez son entrée à cet endroit, puis quittez et rouvrez BusyMax pour utiliser cet interrupteur.'; + @override String get timeFormat => 'Format de l’heure'; diff --git a/lib/l10n/generated/app_localizations_hi.dart b/lib/l10n/generated/app_localizations_hi.dart index 90187393..ea176ad1 100644 --- a/lib/l10n/generated/app_localizations_hi.dart +++ b/lib/l10n/generated/app_localizations_hi.dart @@ -12,6 +12,10 @@ import 'app_localizations.dart'; class AppLocalizationsHi extends AppLocalizations { AppLocalizationsHi([String locale = 'hi']) : super(locale); + @override + String get launchAtLoginManagedExternally => + 'BusyMax आपके डेस्कटॉप की स्टार्टअप सेटिंग से शुरू हुआ था। वहाँ से इसकी प्रविष्टि हटाएँ, फिर इस स्विच का उपयोग करने के लिए BusyMax से बाहर निकलें और इसे दोबारा खोलें।'; + @override String get timeFormat => 'समय प्रारूप'; diff --git a/lib/l10n/generated/app_localizations_it.dart b/lib/l10n/generated/app_localizations_it.dart index a118f630..33bd59b7 100644 --- a/lib/l10n/generated/app_localizations_it.dart +++ b/lib/l10n/generated/app_localizations_it.dart @@ -12,6 +12,10 @@ import 'app_localizations.dart'; class AppLocalizationsIt extends AppLocalizations { AppLocalizationsIt([String locale = 'it']) : super(locale); + @override + String get launchAtLoginManagedExternally => + 'BusyMax è stato avviato dalle impostazioni di avvio del desktop. Rimuovi la sua voce da lì, poi esci da BusyMax e riaprilo per usare questo interruttore.'; + @override String get timeFormat => 'Formato ora'; diff --git a/lib/l10n/generated/app_localizations_ja.dart b/lib/l10n/generated/app_localizations_ja.dart index 1a3121b2..2466f1be 100644 --- a/lib/l10n/generated/app_localizations_ja.dart +++ b/lib/l10n/generated/app_localizations_ja.dart @@ -12,6 +12,10 @@ import 'app_localizations.dart'; class AppLocalizationsJa extends AppLocalizations { AppLocalizationsJa([String locale = 'ja']) : super(locale); + @override + String get launchAtLoginManagedExternally => + 'BusyMax はデスクトップの自動起動設定によって起動されました。このスイッチを使うには、そこで BusyMax の項目を削除してから、BusyMax を終了して開き直してください。'; + @override String get timeFormat => '時刻の表示形式'; diff --git a/lib/l10n/generated/app_localizations_ko.dart b/lib/l10n/generated/app_localizations_ko.dart index 42871a56..69ce9937 100644 --- a/lib/l10n/generated/app_localizations_ko.dart +++ b/lib/l10n/generated/app_localizations_ko.dart @@ -12,6 +12,10 @@ import 'app_localizations.dart'; class AppLocalizationsKo extends AppLocalizations { AppLocalizationsKo([String locale = 'ko']) : super(locale); + @override + String get launchAtLoginManagedExternally => + '데스크톱의 시작 프로그램 설정에 의해 BusyMax가 실행되었습니다. 이 스위치를 사용하려면 해당 설정에서 BusyMax 항목을 제거한 다음 BusyMax를 완전히 종료하고 다시 여세요.'; + @override String get timeFormat => '시간 형식'; diff --git a/lib/l10n/generated/app_localizations_pl.dart b/lib/l10n/generated/app_localizations_pl.dart index a4b4f087..dc2fd2b9 100644 --- a/lib/l10n/generated/app_localizations_pl.dart +++ b/lib/l10n/generated/app_localizations_pl.dart @@ -12,6 +12,10 @@ import 'app_localizations.dart'; class AppLocalizationsPl extends AppLocalizations { AppLocalizationsPl([String locale = 'pl']) : super(locale); + @override + String get launchAtLoginManagedExternally => + 'BusyMax został uruchomiony przez ustawienia autostartu pulpitu. Usuń stamtąd jego wpis, a następnie zakończ i ponownie uruchom BusyMax, aby użyć tego przełącznika.'; + @override String get timeFormat => 'Format czasu'; diff --git a/lib/l10n/generated/app_localizations_pt.dart b/lib/l10n/generated/app_localizations_pt.dart index 1d3f5cab..4dc2de3c 100644 --- a/lib/l10n/generated/app_localizations_pt.dart +++ b/lib/l10n/generated/app_localizations_pt.dart @@ -12,6 +12,10 @@ import 'app_localizations.dart'; class AppLocalizationsPt extends AppLocalizations { AppLocalizationsPt([String locale = 'pt']) : super(locale); + @override + String get launchAtLoginManagedExternally => + 'BusyMax was launched by your desktop\'s startup settings. Remove its entry there, then quit and reopen BusyMax to use this switch.'; + @override String get timeFormat => 'Time format'; @@ -2959,6 +2963,10 @@ class AppLocalizationsPt extends AppLocalizations { class AppLocalizationsPtPt extends AppLocalizationsPt { AppLocalizationsPtPt() : super('pt_PT'); + @override + String get launchAtLoginManagedExternally => + 'O BusyMax foi iniciado pelas definições de arranque do ambiente de trabalho. Remova a respetiva entrada, depois saia do BusyMax e volte a abri-lo para utilizar este interruptor.'; + @override String get timeFormat => 'Formato da hora'; diff --git a/lib/l10n/generated/app_localizations_ru.dart b/lib/l10n/generated/app_localizations_ru.dart index 8107a4ce..102871b6 100644 --- a/lib/l10n/generated/app_localizations_ru.dart +++ b/lib/l10n/generated/app_localizations_ru.dart @@ -12,6 +12,10 @@ import 'app_localizations.dart'; class AppLocalizationsRu extends AppLocalizations { AppLocalizationsRu([String locale = 'ru']) : super(locale); + @override + String get launchAtLoginManagedExternally => + 'BusyMax запущен через настройки автозапуска рабочего стола. Удалите его запись там, затем полностью завершите и снова откройте BusyMax, чтобы использовать этот переключатель.'; + @override String get timeFormat => 'Формат времени'; diff --git a/lib/l10n/generated/app_localizations_vi.dart b/lib/l10n/generated/app_localizations_vi.dart index 10ca3c5e..5d471410 100644 --- a/lib/l10n/generated/app_localizations_vi.dart +++ b/lib/l10n/generated/app_localizations_vi.dart @@ -12,6 +12,10 @@ import 'app_localizations.dart'; class AppLocalizationsVi extends AppLocalizations { AppLocalizationsVi([String locale = 'vi']) : super(locale); + @override + String get launchAtLoginManagedExternally => + 'BusyMax được mở bởi cài đặt khởi động của môi trường máy tính. Xóa mục BusyMax ở đó, rồi thoát hẳn và mở lại BusyMax để dùng công tắc này.'; + @override String get timeFormat => 'Định dạng giờ'; diff --git a/lib/l10n/generated/app_localizations_zh.dart b/lib/l10n/generated/app_localizations_zh.dart index fd5047c2..15508cd9 100644 --- a/lib/l10n/generated/app_localizations_zh.dart +++ b/lib/l10n/generated/app_localizations_zh.dart @@ -12,6 +12,10 @@ import 'app_localizations.dart'; class AppLocalizationsZh extends AppLocalizations { AppLocalizationsZh([String locale = 'zh']) : super(locale); + @override + String get launchAtLoginManagedExternally => + 'BusyMax 由桌面的启动设置启动。请在那里移除 BusyMax 的条目,然后完全退出并重新打开 BusyMax,以使用此开关。'; + @override String get timeFormat => '时间格式'; @@ -2827,6 +2831,10 @@ class AppLocalizationsZh extends AppLocalizations { class AppLocalizationsZhHans extends AppLocalizationsZh { AppLocalizationsZhHans() : super('zh_Hans'); + @override + String get launchAtLoginManagedExternally => + 'BusyMax 由桌面的启动设置启动。请在那里移除 BusyMax 的条目,然后完全退出并重新打开 BusyMax,以使用此开关。'; + @override String get timeFormat => '时间格式'; @@ -5642,6 +5650,10 @@ class AppLocalizationsZhHans extends AppLocalizationsZh { class AppLocalizationsZhHant extends AppLocalizationsZh { AppLocalizationsZhHant() : super('zh_Hant'); + @override + String get launchAtLoginManagedExternally => + 'BusyMax 由桌面的啟動設定啟動。請在那裡移除 BusyMax 的項目,然後完全結束並重新開啟 BusyMax,以使用此開關。'; + @override String get timeFormat => '時間格式'; diff --git a/lib/src/features/settings/presentation/settings_screen.dart b/lib/src/features/settings/presentation/settings_screen.dart index f1b0a1fd..30a2bb67 100644 --- a/lib/src/features/settings/presentation/settings_screen.dart +++ b/lib/src/features/settings/presentation/settings_screen.dart @@ -265,6 +265,9 @@ class _SettingsScreenState extends ConsumerState { title: l10n.launchAtLogin, subtitle: launchAtLogin.hasError ? l10n.launchAtLoginReadFailed + : launchAtLogin.valueOrNull == + DesktopAutostartState.enabledExternally + ? l10n.launchAtLoginManagedExternally : launchAtLogin.valueOrNull == DesktopAutostartState.unavailable ? l10n.launchAtLoginUnavailable diff --git a/lib/src/platform/common/desktop_services.dart b/lib/src/platform/common/desktop_services.dart index 747bb352..ba5dad77 100644 --- a/lib/src/platform/common/desktop_services.dart +++ b/lib/src/platform/common/desktop_services.dart @@ -31,6 +31,9 @@ abstract interface class DesktopTrayService { enum DesktopAutostartState { enabled, enabledByPolicy, + // The current process was started by a desktop entry outside the app's + // control. Its current on-disk state cannot be checked from a confined Snap. + enabledExternally, disabled, disabledByUser, disabledByPolicy, @@ -40,7 +43,8 @@ enum DesktopAutostartState { extension DesktopAutostartStateX on DesktopAutostartState { bool get isEnabled => this == DesktopAutostartState.enabled || - this == DesktopAutostartState.enabledByPolicy; + this == DesktopAutostartState.enabledByPolicy || + this == DesktopAutostartState.enabledExternally; bool get canChange => this == DesktopAutostartState.enabled || diff --git a/lib/src/platform/linux_autostart_service.dart b/lib/src/platform/linux_autostart_service.dart index 8b4ddf03..3574c895 100644 --- a/lib/src/platform/linux_autostart_service.dart +++ b/lib/src/platform/linux_autostart_service.dart @@ -11,25 +11,30 @@ final class LinuxAutostartService implements DesktopAutostartService { Map? environment, String? executable, bool? isLinux, + int? processId, }) : _environment = environment ?? Platform.environment, _executable = executable ?? Platform.resolvedExecutable, - _isLinux = isLinux ?? Platform.isLinux; + _isLinux = isLinux ?? Platform.isLinux, + _processId = processId ?? pid; final Map _environment; final String _executable; final bool _isLinux; + final int _processId; Future _mutationTail = Future.value(); - Future isEnabled() async { - if (!_isLinux) return false; - final file = _autostartFile(); - final String contents; + Future isEnabled() async => (await state()).isEnabled; + + Future _readEntry(File file) async { try { - contents = await file.readAsString(); + return await file.readAsString(); } on FileSystemException catch (error) { - if (error.osError?.errorCode == 2) return false; // ENOENT + if (error.osError?.errorCode == 2) return null; // ENOENT rethrow; } + } + + Future _entryIsEnabled(String contents) async { final entry = {}; var inDesktopEntry = false; for (final rawLine in contents.split('\n')) { @@ -91,9 +96,19 @@ final class LinuxAutostartService implements DesktopAutostartService { @override Future state() async { if (!_isLinux) return DesktopAutostartState.unavailable; - return await isEnabled() - ? DesktopAutostartState.enabled - : DesktopAutostartState.disabled; + if (_wasStartedByExternalDesktopEntry) { + return DesktopAutostartState.enabledExternally; + } + // XDG uses the first existing entry, even when it is hidden or disabled. + // Snap's autostart mechanism only uses its own user configuration file. + for (final file in [_autostartFile(), ..._systemAutostartFiles()]) { + final contents = await _readEntry(file); + if (contents == null) continue; + return await _entryIsEnabled(contents) + ? DesktopAutostartState.enabled + : DesktopAutostartState.disabled; + } + return DesktopAutostartState.disabled; } @override @@ -113,19 +128,37 @@ final class LinuxAutostartService implements DesktopAutostartService { if (!_isLinux) { throw UnsupportedError('Launch at login is supported only on Linux.'); } + if (_wasStartedByExternalDesktopEntry) { + throw StateError('Launch at login is managed by the desktop.'); + } final file = _autostartFile(); if (!enabled) { + for (final systemFile in _systemAutostartFiles()) { + if (await _readEntry(systemFile) != null) { + // Deleting the user entry would expose an inherited entry again. + await _writeEntry(file, '''[Desktop Entry] +Type=Application +Name=BusyMax +Hidden=true +'''); + return; + } + } if (await file.exists()) await file.delete(); return; } + await _writeEntry(file, _desktopEntry()); + } + + Future _writeEntry(File file, String contents) async { await file.parent.create(recursive: true); final temporaryDirectory = await file.parent.createTemp( '.busymax-autostart-', ); final temporary = File(path.join(temporaryDirectory.path, 'entry.desktop')); try { - await temporary.writeAsString(_desktopEntry()); + await temporary.writeAsString(contents); await temporary.rename(file.path); } finally { if (await temporary.exists()) await temporary.delete(); @@ -138,6 +171,39 @@ final class LinuxAutostartService implements DesktopAutostartService { return File(path.join(configHome, 'autostart', busyMaxAutostartFileName)); } + bool get _isSnap => _environment['SNAP']?.trim().isNotEmpty ?? false; + + bool get _wasStartedByExternalDesktopEntry { + if (!_isSnap) return false; + // GIO metadata can be inherited from an IDE or terminal. Only use it when + // it identifies this process, and never read/write paths outside the Snap. + if (_environment['GIO_LAUNCHED_DESKTOP_FILE_PID'] != '$_processId') { + return false; + } + final launchedFile = _environment['GIO_LAUNCHED_DESKTOP_FILE']; + if (launchedFile == null || !path.isAbsolute(launchedFile)) return false; + final normalized = path.normalize(launchedFile); + return path.extension(normalized) == '.desktop' && + path.basename(path.dirname(normalized)) == 'autostart' && + !path.equals( + path.dirname(normalized), + path.normalize(path.join(_configHome(), 'autostart')), + ); + } + + Iterable _systemAutostartFiles() sync* { + if (_isSnap) return; + final configured = _environment['XDG_CONFIG_DIRS']; + final directories = configured == null || configured.isEmpty + ? ['/etc/xdg'] + : configured.split(':'); + for (final directory in directories.toSet()) { + // The XDG base directory specification ignores relative paths. + if (!path.isAbsolute(directory)) continue; + yield File(path.join(directory, 'autostart', busyMaxAutostartFileName)); + } + } + String _configHome() { final configured = _environment['XDG_CONFIG_HOME']?.trim(); if (configured != null && configured.isNotEmpty) { @@ -158,8 +224,7 @@ final class LinuxAutostartService implements DesktopAutostartService { } String _desktopEntry() { - final snap = _environment['SNAP']?.trim().isNotEmpty ?? false; - final command = snap + final command = _isSnap ? 'busymax $busyMaxStartMinimizedArgument' : '${_desktopExecArgument(_executable)} ' '$busyMaxStartMinimizedArgument'; diff --git a/test/features/settings/presentation/settings_screen_test.dart b/test/features/settings/presentation/settings_screen_test.dart index 3459cb96..353b38ac 100644 --- a/test/features/settings/presentation/settings_screen_test.dart +++ b/test/features/settings/presentation/settings_screen_test.dart @@ -280,6 +280,30 @@ void main() { }, ); + testWidgets('external startup is shown as on with desktop instructions', ( + tester, + ) async { + final service = FakeAutostartService() + ..current = DesktopAutostartState.enabledExternally; + final container = _autostartContainer(service); + addTearDown(container.dispose); + await _pumpDefaultSettings( + tester, + container, + logicalSize: const Size(1000, 900), + ); + expect(_launchSwitch(tester).value, isTrue); + expect(_launchSwitch(tester).onChanged, isNull); + expect( + find.text( + "BusyMax was launched by your desktop's startup settings. " + 'Remove its entry there, then quit and reopen BusyMax to use this switch.', + ), + findsOneWidget, + ); + expect(service.writes, isEmpty); + }); + testWidgets('Settings inventories visible and provider-hidden calendars', ( tester, ) async { diff --git a/test/platform/linux_autostart_service_test.dart b/test/platform/linux_autostart_service_test.dart index f8767b7d..02e2cbe2 100644 --- a/test/platform/linux_autostart_service_test.dart +++ b/test/platform/linux_autostart_service_test.dart @@ -6,6 +6,211 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:path/path.dart' as path; void main() { + group('effective Linux startup state', () { + late Directory root; + late String configHome; + late String systemConfig; + late String fallbackConfig; + late Map environment; + late LinuxAutostartService service; + const activeEntry = '[Desktop Entry]\nType=Application\nExec=busymax\n'; + + Future writeEntry(String directory, String contents) async { + final file = File( + path.join(directory, 'autostart', busyMaxAutostartFileName), + ); + await file.parent.create(recursive: true); + return file.writeAsString(contents); + } + + setUp(() async { + root = await Directory.systemTemp.createTemp( + 'busymax-effective-startup-', + ); + configHome = path.join(root.path, 'user'); + systemConfig = path.join(root.path, 'system'); + fallbackConfig = path.join(root.path, 'fallback'); + environment = { + 'XDG_CONFIG_HOME': configHome, + 'XDG_CONFIG_DIRS': '$systemConfig:$fallbackConfig', + 'XDG_CURRENT_DESKTOP': 'ubuntu:GNOME', + }; + service = LinuxAutostartService( + environment: environment, + executable: '/opt/BusyMax/busymax', + isLinux: true, + processId: 123, + ); + }); + tearDown(() => root.delete(recursive: true)); + + test( + 'reads an inherited entry and disables it with a user override', + () async { + final inherited = await writeEntry(systemConfig, activeEntry); + expect(await service.state(), DesktopAutostartState.enabled); + + await service.setEnabled(false); + + expect(await service.state(), DesktopAutostartState.disabled); + final override = File( + path.join(configHome, 'autostart', busyMaxAutostartFileName), + ); + expect(await override.readAsString(), contains('Hidden=true')); + expect(await inherited.readAsString(), activeEntry); + await service.setEnabled(false); + expect(await service.isEnabled(), isFalse); + + await service.setEnabled(true); + expect(await service.isEnabled(), isTrue); + expect(await override.readAsString(), isNot(contains('Hidden=true'))); + expect(await inherited.readAsString(), activeEntry); + }, + ); + + test('disabling a user entry does not expose an inherited entry', () async { + await writeEntry(systemConfig, activeEntry); + await service.setEnabled(true); + await service.setEnabled(false); + expect(await service.isEnabled(), isFalse); + }); + + test( + 'hidden user and system entries mask lower priority entries', + () async { + await writeEntry(fallbackConfig, activeEntry); + expect(await service.isEnabled(), isTrue); + await writeEntry(systemConfig, '[Desktop Entry]\nHidden=true\n'); + expect(await service.isEnabled(), isFalse); + await service.setEnabled(true); + expect(await service.isEnabled(), isTrue); + await writeEntry(configHome, '[Desktop Entry]\nHidden=true\n'); + await writeEntry(systemConfig, activeEntry); + expect(await service.isEnabled(), isFalse); + }, + ); + + test( + 'desktop restrictions do not fall back to a lower priority entry', + () async { + await writeEntry(systemConfig, '${activeEntry}OnlyShowIn=KDE;\n'); + await writeEntry(fallbackConfig, activeEntry); + expect(await service.isEnabled(), isFalse); + }, + ); + + test( + 'ignores relative XDG directories and keeps absolute directory order', + () async { + environment['XDG_CONFIG_DIRS'] = + 'relative::$systemConfig:$fallbackConfig'; + await writeEntry(systemConfig, '[Desktop Entry]\nHidden=true\n'); + await writeEntry(fallbackConfig, activeEntry); + expect(await service.isEnabled(), isFalse); + }, + ); + + test( + 'an unreadable effective entry is an error, not an Off result', + () async { + await Directory( + path.join(systemConfig, 'autostart', busyMaxAutostartFileName), + ).create(recursive: true); + await expectLater(service.state(), throwsA(isA())); + }, + ); + + test( + 'atomic override replaces a user symlink without modifying its target', + () async { + final inherited = await writeEntry(systemConfig, activeEntry); + final userDirectory = Directory(path.join(configHome, 'autostart')); + await userDirectory.create(recursive: true); + final link = Link( + path.join(userDirectory.path, busyMaxAutostartFileName), + ); + await link.create(inherited.path); + await service.setEnabled(false); + expect(await service.isEnabled(), isFalse); + expect(await inherited.readAsString(), activeEntry); + expect(await link.exists(), isFalse); + }, + skip: !Platform.isLinux, + ); + + test('Snap uses only its own autostart entry', () async { + environment['SNAP'] = '/snap/busymax/current'; + await writeEntry(systemConfig, activeEntry); + expect(await service.isEnabled(), isFalse); + await service.setEnabled(true); + expect(await service.isEnabled(), isTrue); + await service.setEnabled(false); + expect(await service.isEnabled(), isFalse); + expect( + await File( + path.join(configHome, 'autostart', busyMaxAutostartFileName), + ).exists(), + isFalse, + ); + }); + + test( + 'Snap reports the GNOME startup launch as externally managed', + () async { + environment.addAll({ + 'SNAP': '/snap/busymax/current', + 'GIO_LAUNCHED_DESKTOP_FILE': + '/home/example/.config/autostart/busymax_busymax.desktop', + 'GIO_LAUNCHED_DESKTOP_FILE_PID': '123', + }); + expect(await service.state(), DesktopAutostartState.enabledExternally); + expect(await service.isEnabled(), isTrue); + expect((await service.state()).canChange, isFalse); + for (final enabled in [true, false]) { + await expectLater(service.setEnabled(enabled), throwsStateError); + } + expect(await Directory(configHome).exists(), isFalse); + // The external entry still controls startup even if a Snap entry exists. + await writeEntry(configHome, activeEntry); + expect(await service.state(), DesktopAutostartState.enabledExternally); + }, + ); + + test('Snap ignores inherited GIO metadata from another process', () async { + environment.addAll({ + 'SNAP': '/snap/busymax/current', + 'GIO_LAUNCHED_DESKTOP_FILE': + '/home/example/.config/autostart/ide.desktop', + 'GIO_LAUNCHED_DESKTOP_FILE_PID': '456', + }); + expect(await service.state(), DesktopAutostartState.disabled); + environment.remove('GIO_LAUNCHED_DESKTOP_FILE_PID'); + expect(await service.state(), DesktopAutostartState.disabled); + }); + + test( + 'Snap menu launches and its own startup entry remain changeable', + () async { + environment.addAll({ + 'SNAP': '/snap/busymax/current', + 'GIO_LAUNCHED_DESKTOP_FILE_PID': '123', + }); + for (final launchedFile in [ + '/var/lib/snapd/desktop/applications/busymax_busymax.desktop', + 'relative/autostart/busymax.desktop', + '/home/example/.config/autostart/busymax.desktop.disabled', + path.join(configHome, 'autostart', busyMaxAutostartFileName), + ]) { + environment['GIO_LAUNCHED_DESKTOP_FILE'] = launchedFile; + expect(await service.state(), DesktopAutostartState.disabled); + await service.setEnabled(true); + expect(await service.state(), DesktopAutostartState.enabled); + await service.setEnabled(false); + } + }, + ); + }); + for (final entryCase in <({String contents, bool enabled})>[ (contents: 'Hidden=true', enabled: false), (contents: 'Hidden=false', enabled: true), diff --git a/test/platform/windows/windows_autostart_service_test.dart b/test/platform/windows/windows_autostart_service_test.dart index 6a9641b6..409eae53 100644 --- a/test/platform/windows/windows_autostart_service_test.dart +++ b/test/platform/windows/windows_autostart_service_test.dart @@ -11,7 +11,9 @@ void main() { TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; tearDown(() => messenger.setMockMethodCallHandler(channel, null)); - for (final state in DesktopAutostartState.values) { + for (final state in DesktopAutostartState.values.where( + (state) => state != DesktopAutostartState.enabledExternally, + )) { test('maps and enforces Windows startup state ${state.name}', () async { var writes = 0; messenger.setMockMethodCallHandler(channel, (call) async { From 356ff0fdc01aa52afd207e9acaadbdfae13a5023 Mon Sep 17 00:00:00 2001 From: albert Date: Mon, 14 Sep 2026 19:20:59 -0700 Subject: [PATCH 05/25] Refactored Android notifications: - Introduced `AndroidNotificationBackend` for improved abstraction. - Replaced `_plugin` dependencies with `AndroidNotificationBackend`. - Enhanced active notification visibility during reconciliation. - Improved recovery handling, write blocking, and draft persistence checks. - Added Microsoft Time Zone support and validated draft recovery consistency. - Streamlined state updates, discard handling, and save/load routines. --- docs/android_provider_parity.md | 16 + docs/android_verification.md | 64 +- docs/provider_support_matrix.md | 8 + lib/src/android/android_notifications.dart | 243 +++- .../android_availability_dialog.dart | 214 +++ .../android_diagnostics_screen.dart | 189 +++ .../presentation/android_feedback_screen.dart | 275 ++++ .../android_nextcloud_scheduling_screen.dart | 134 ++ .../presentation/android_schedule_screen.dart | 610 +++++++-- .../presentation/android_settings_screen.dart | 300 +++-- .../presentation/android_tasks_screen.dart | 347 +++-- .../BusymaxAndroidPlatformPlugin.kt | 69 +- .../BusymaxAndroidPlatformPluginTest.kt | 36 + .../android/android_adaptive_app_test.dart | 1159 ++++++++++++++++- .../android_local_network_host_test.dart | 48 + .../android_month_grid_range_test.dart | 81 ++ .../android_notification_policy_test.dart | 15 + ...roid_notification_reconciliation_test.dart | 465 +++++++ 18 files changed, 3892 insertions(+), 381 deletions(-) create mode 100644 lib/src/android/presentation/android_availability_dialog.dart create mode 100644 lib/src/android/presentation/android_diagnostics_screen.dart create mode 100644 lib/src/android/presentation/android_feedback_screen.dart create mode 100644 lib/src/android/presentation/android_nextcloud_scheduling_screen.dart create mode 100644 test/platform/android/android_month_grid_range_test.dart create mode 100644 test/platform/android/android_notification_reconciliation_test.dart diff --git a/docs/android_provider_parity.md b/docs/android_provider_parity.md index e330db91..0f740685 100644 --- a/docs/android_provider_parity.md +++ b/docs/android_provider_parity.md @@ -13,6 +13,22 @@ selected concrete collection. | Nextcloud | Login Flow v2 and secure app-password storage | CalDAV read/write, native ICS, collection permissions, and Android sharing/publishing/trash/restore routes | VTODO read/write with per-collection DAV capabilities; due/start/completion, status/progress/priority, recurrence, multiple alarms, hierarchy, duplicate/move and native export are exposed when supported | Calendar/task-list create, rename, delete/remove, sharing grants, publish/unpublish, trash restore/permanent delete, reminders, and document-tree native export are available only when server privileges allow them. Local hosts use contextual Android 17 LAN permission and denial aborts onboarding. | | WebCal | Confirmed subscription URL; embedded credentials remain in credential storage | Read-only subscription refresh and local rename/color metadata | Not supported | No event mutation or provider collection write is presented. | +## Android workflow entry points + +| Workflow | Visible route | Capability and permission gate | Input and result behavior | +|---|---|---|---| +| Invitation response | Schedule item detail > **Accept**, **Tentative**, or **Decline** | The current user must be an attendee. Google and Microsoft use their existing response mutations; Nextcloud also requires scheduling reply capability and asks for occurrence/series scope when applicable. | Updates the shared local event projection, queues the account-specific calendar sync, and reports a redacted failure without inventing support for Apple or WebCal. | +| Guest availability | Event editor > **Check guest availability** | A valid start/end and at least one guest are required. Google uses the shared free/busy API. Nextcloud additionally requires a DAV collection with `canQueryFreeBusy`. Microsoft, Apple, and WebCal do not show the action. | Queries the edited guest set and draft interval, then shows a bounded per-recipient free/busy result with refresh and error states. It does not mutate the event. | +| Nextcloud scheduling inbox | Settings > a Nextcloud calendar > **Scheduling inbox** | Requires a Nextcloud DAV calendar; server scheduling capability and responses are enforced by the shared service. | Loads cached then refreshed inbox messages. Acknowledgement is explicitly confirmed and removes the inbox message, not the calendar event. | +| Calendar administration | Settings > calendar > rename/color/delete | Concrete source capabilities determine which actions appear. Queued Google/Microsoft mutations use the existing account-specific pending-calendar sync requester; online DAV administration stays on its shared service path. | Optimistic local changes retain the normal pending/error state and are not duplicated by a second sync subsystem. | +| Diagnostics | Settings > **Diagnostics** | Always local; individual retry/discard actions require a concrete blocked operation. | Shows account/auth and last-sync state, reminder precision, and live blocked operations. Retry is explicit; discard requires confirmation; diagnostic errors are redacted according to Settings. | +| Feedback | Settings > **Send feedback** | Available only when the configured HTTPS feedback endpoint is valid. | Validates category, subject, message, optional reply address, and explicit technical-detail consent; shows stable success/failure state and protects a dirty form from Back. | + +These are UI entry points, not live-provider certification. Provider payload, +projection, and capability tests are deterministic; authorization, delivery, +server interoperability, and Android lifecycle behavior retain the device/live +test boundaries in [android_verification.md](android_verification.md). + All five schedule views are available as distinct implementations: virtual Agenda, time-axis Day/Week, scrollable Month with selected-day agenda, and Year month miniatures. Android defaults independently to Agenda, while the existing diff --git a/docs/android_verification.md b/docs/android_verification.md index e2525629..0f91d02a 100644 --- a/docs/android_verification.md +++ b/docs/android_verification.md @@ -11,10 +11,10 @@ test is never counted as a pass. | Item | Recorded value | |---|---| -| Source baseline | `9350af42e299f9da85ea2d046792a76e3a562cba` | -| Reviewed Android foundation | `263a99e5b747fa344de3c8c775ccda2d4bbdfbe0` plus the uncommitted review corrections described here | +| Source baseline | `b0f816ca4687decd4b502d0f0fdf6ac518de40cc` | +| Reviewed Android foundation | The source baseline plus the uncommitted `BusyMax_Android_a8d0604_Review.md` corrections described here | | Verification host | `skynet3`, Linux x86_64, Ubuntu kernel `7.0.0-31-generic` | -| Verification time | `2026-09-14T16:44:01-07:00` (`America/Vancouver`) | +| Verification time | Current correction pass on `2026-09-14` (`America/Vancouver`) | | Flutter / Dart | Flutter `3.47.2` (`d3b14c8769`), bundled Dart `3.13.2` | | Java | Eclipse Temurin JDK `17.0.18+8` | | Android toolchain | platform `37.0` revision 2, Build Tools `37.0.0`, Gradle `9.4.1`, AGP `9.2.0` | @@ -27,42 +27,46 @@ test is never counted as a pass. | Status | Check | Command or procedure | Evidence | |---|---|---|---| | PASSED | Pinned SDK prerequisites | `tool/android/check_prerequisites.sh` with Temurin JDK 17 | Flutter 3.47.2, Dart 3.13.2, API 37 and Build Tools 37.x accepted | -| PASSED | Localization and Drift generation | `flutter gen-l10n`; bundled `dart run build_runner build --force-jit` | Generated localization and schema sources are current; the existing Drift duplicate-reference warning remains non-fatal | -| PASSED | Formatting | bundled `dart format --output=none --set-exit-if-changed .` | 713 files unchanged by the check | -| PASSED | Static analysis | `flutter analyze` | `No issues found!` | -| PASSED | Platform boundary enforcement | bundled `dart run tool/check_platform_boundaries.dart` | Android foreground/headless graphs contain no desktop imports; malicious-fixture regression also passed | -| PASSED | Unit and widget suite | `flutter test --reporter compact` | 2,223 runnable tests passed; 10 live/environment tests skipped and are not reported as passes; terminal result: `All other tests passed!` | -| PASSED | Android adaptive widget matrix | `flutter test test/platform/android/android_adaptive_app_test.dart` | Schedule, Tasks and Settings exercised at 360x800, 412x915, 800x1280, landscape 915x412 and split 320x500, text scales 1.0/1.3/2.0, light/dark and RTL. Populated fixtures verify the five calendar renderers, an overnight event on its intersecting day, hidden calendar/task-list exclusion, read-only event/task actions, and discard confirmation for modified event/task editors. No overflow; compact/wide navigation, labels and 48 dp controls checked. | -| PASSED | Android notification policy | `flutter test test/platform/android/android_notification_policy_test.dart` | Stable IDs/collision handling, post-quiet-hours eligibility, pending overdue inexact retention, privacy/exact registration replacement, explicit Open UI action and generation-bearing payload parsing | +| NOT RUN | Localization and Drift generation | `flutter gen-l10n`; bundled `dart run build_runner build --force-jit` | No localization or schema input changed in this correction pass. The earlier result is not relabeled as a current run. | +| PASSED | Repository-wide formatting | bundled `dart format --output=none --set-exit-if-changed .` | All 719 Dart files were already formatted; the check made no changes. | +| PASSED | Repository-wide static analysis | `flutter analyze` | `No issues found!` for the current correction working tree. | +| PASSED | Platform boundary enforcement | bundled `dart run tool/check_platform_boundaries.dart` | Current Android foreground/headless composition contains no prohibited desktop imports. | +| PASSED | Repository-wide unit and widget suite | `flutter test --concurrency=1 --reporter compact` | 2,271 tests passed and 10 explicitly environment-gated tests were skipped in the current correction working tree. The skipped live-integration cases are not counted as passes. | +| PASSED | Android platform test suite | `flutter test test/platform/android --concurrency=1` | 66 current Android unit/widget tests passed in one serialized run. This includes the scenario-specific rows below; it is not counted again as an additional 66 scenarios. | +| PASSED | Affected shared regression set | `flutter test` over calendar collection/repository, pending sync requester, Nextcloud scheduling service/policy, and feedback submission/presentation test files with `--concurrency=1` | 100 tests passed. These verify shared payload/projection/service behavior used by the new Android routes; they do not turn widget navigation into a live-provider round trip. | +| PASSED | Android adaptive widget matrix | `flutter test test/platform/android/android_adaptive_app_test.dart --concurrency=1` | 29 widget tests passed. The empty shell matrix covers the required sizes/themes/RTL. Populated flows explicitly select Day, Week, Month, and Year, exercise all-day/date-only overflow at compact and wide Week widths, submit and inspect an invitation response, and render Month/Year at 320x500 and 200% text. System Back is exercised independently for every named event/task text field. Stateful secure-storage tests round-trip changed event destinations, recurring scope, and advanced task recurrence. Dirty task save-and-move, cancel, and failure paths inspect real database/recovery state. Settings routes, Apple credential-prompt lifecycle, guest-availability error UI, Google calendar rename/color/delete sync requests, and feedback failure/retry identity are exercised. | +| PASSED | Android notification policy | `flutter test test/platform/android/android_notification_policy_test.dart --concurrency=1` | 8 helper/contract tests passed: stable IDs, quiet-hours eligibility, pending/displayed retention predicates, privacy/exact fingerprinting, explicit Open UI action, and payload parsing. | +| PASSED | Android notification reconciliation | `flutter test test/platform/android/android_notification_reconciliation_test.dart --concurrency=1` | 7 service tests passed against database rows and a stateful pending/active platform fake: displayed retention, stale generation cancellation, Dismiss, overdue inexact pending retention, Snooze, quiet-hours deferral, and overdue daily-summary privacy/precision replacement. | | PASSED | Android authorization regression | `flutter test test/platform/android/android_authorization_broker_test.dart` | Missing native scopes are not promoted to grants, native interaction-required becomes the shared reconnect error, and non-revoking local Google removal clears its native binding | | PASSED | Android attendee mutation regression | `flutter test test/platform/android/android_event_attendee_edit_test.dart` | Unchanged guest text preserves the original structured attendee objects and change flag; additions retain existing guest metadata | -| PASSED | Android contextual LAN regression | `flutter test test/platform/android/android_local_network_host_test.dart` | Local/contextual DNS, private/link-local IPv4, loopback/ULA/link-local IPv6 and public DNS/IP cases classified independently | +| PASSED | Android contextual LAN regression | `flutter test test/platform/android/android_local_network_host_test.dart --concurrency=1` | 6 tests passed for local suffixes and literals plus injected DNS resolution of ordinary dotted names to private, public, mixed, and failed results. These are resolver/onboarding-policy tests, not an Android 17 socket-permission device test. | +| PASSED | Android month/year range regression | `flutter test test/platform/android/android_month_grid_range_test.dart --concurrency=1` | 4 tests passed for exact 42-day Sunday/Monday-first display-query parity, civil-date stepping across offset-changing dates, and Year query coverage of every miniature's adjacent-month cells. | | PASSED | Android platform channel contract | `flutter test test/platform/android/android_platform_channel_test.dart` | Provider availability, account-specific silent Microsoft request, content-URI behavior, and explicit engine-owned gate release contract | | PASSED | Android settings persistence | `flutter test test/platform/android/android_settings_persistence_test.dart` | Android view preference is independent and atomically persisted | -| PASSED | Native plugin unit tests | `android/gradlew --project-dir android :busymax_android_platform:testDebugUnitTest` | 2 tests, 0 failures/errors/skips in the JUnit report under `build/busymax_android_platform/test-results/testDebugUnitTest/`; unknown methods return `notImplemented`, and releasing an engine owner frees its held account lease | -| PASSED | Android lint | `android/gradlew --project-dir android :app:lintDebug` | [lint-results-debug.html](../build/app/reports/lint-results-debug.html) | -| PASSED | Release-mode shrinking and packaging | `tool/android/build_release.sh --all --test-signing --allow-unconfigured-providers` | R8/resource shrinking completed; final APK and AAB paths are recorded below | -| PASSED | Release artifact inspection | `tool/android/inspect_artifacts.sh` | [android-artifact-inspection.txt](../build/android/android-artifact-inspection.txt) | -| BLOCKED | End-to-end clean-snapshot wrapper run | Export JDK 17 and `ANDROID_SDK_ROOT`, then run `tool/android/verify.sh` in a clean source snapshot | The clean snapshot passed prerequisite and lockfile resolution and entered deterministic generation, but this host terminated the duplicate heavy process with signal 9. Every component command is independently recorded as passed above in the real workspace; no clean-snapshot pass is claimed. | +| PASSED | Native plugin unit tests | `JAVA_HOME=.../java/17.0.18-tem android/gradlew --project-dir android :busymax_android_platform:testDebugUnitTest --no-daemon --max-workers=1` | 3 native unit tests passed, including UI-required versus service/network/configuration MSAL classification and engine-owned lease release. This does not exercise a live MSAL account. | +| PASSED | Android lint | `JAVA_HOME=.../java/17.0.18-tem android/gradlew --project-dir android :app:lintDebug --no-daemon --max-workers=1` | Current correction tree completed successfully; [lint-results-debug.html](../build/app/reports/lint-results-debug.html). Dependency/build deprecation warnings remain non-fatal and are not reported as app lint findings. | +| PASSED | Current release packaging | Explicit Temurin 17 path and one-worker `tool/android/build_release.sh --all --test-signing --allow-unconfigured-providers` | Current correction tree produced the APK and AAB below. Both use the local debug identity and unconfigured-provider allowance, so this is engineering packaging evidence, not production readiness. | +| PASSED | Current artifact inspection | Temurin 17 `tool/android/inspect_artifacts.sh` | Current APK/AAB hashes and ABIs recorded below; APK v2 signature, manifest/SDK/permission values, and packaged ELF 16 KB LOAD-segment compatibility passed. Bundletool metadata remains the separate blocked row. | +| BLOCKED | End-to-end clean-snapshot wrapper run | Export JDK 17 and `ANDROID_SDK_ROOT`, then run `tool/android/verify.sh` in a clean source snapshot | The earlier clean snapshot entered deterministic generation but the host terminated that duplicate heavy process with signal 9. No current clean-snapshot wrapper pass is claimed; the successful current repository-wide suite and serialized component checks are recorded separately above. | | BLOCKED | Bundletool AAB configuration dump | `tool/android/inspect_artifacts.sh` | `BUNDLETOOL_JAR` was not installed. Supply a reviewed official bundletool jar and rerun. AAB ZIP contents and every packaged ELF were still inspected directly. | | BLOCKED | Android integration test | `flutter test integration_test/android_platform_smoke_test.dart -d DEVICE_ID` | No Android device was attached. Supply an API 24/36/37 emulator or phone. | | NOT RUN | Windows workflow | Existing Windows CI on an appropriate Windows runner | No Windows runner was available from this Linux workspace; no Windows result is claimed. | -The existing Linux-compatible shared test suite passed from the supplied -baseline before Android changes and passed again after the implementation. Android composition has an explicit `lib/main_android.dart`; the Linux and Windows entrypoints remain separate. -## Release artifact record +## Current test-signed release artifact record -The following artifacts are release-mode but deliberately signed with the -local Android debug identity. They are suitable for internal engineering -inspection only, not owner distribution or Play upload. +The following artifacts were produced from the current correction working +tree. They are release-mode artifacts deliberately signed with the local +Android debug identity and allow unconfigured provider registrations. They are +suitable for engineering inspection only and must not be distributed or +uploaded. | Artifact | Size | SHA-256 | |---|---:|---| -| `build/app/outputs/flutter-apk/app-release.apk` | 85,965,568 bytes | `b7f7cec2bbce986eb9e0313a03662d93b2073a8bb6c88a4476ef48647935f94e` | -| `build/app/outputs/bundle/release/app-release.aab` | 81,956,754 bytes | `7a2838b86d5afe46fcf0163fe3147163bd7059ba78f1046c411715b2ac116105` | +| `build/app/outputs/flutter-apk/app-release.apk` | 86,948,944 bytes | `9e7a0ee03242e5d206e3c5df724b4bfcf999342aa15e3853275c72e5558545f5` | +| `build/app/outputs/bundle/release/app-release.aab` | 82,776,685 bytes | `e48f2e64db3bb0186fb898ae26a36e40746efe9cc1e2645a26a9b49ae577848a` | Inspection found `arm64-v8a`, `armeabi-v7a`, and `x86_64`, with Flutter, application, Dart JNI, and SQLite native libraries in each ABI. Every packaged @@ -94,19 +98,19 @@ provider account, or owner registration. | BLOCKED | Bootstrap and navigation — installed app | Fresh/returning install, process restoration, and cold/warm file/notification launches require an Android device and installed APK. | | PASSED | Google — implementation/contract | Native `AuthorizationClient`, explicit selection, exact BusyMax/native binding, provider-reported grant persistence, required-scope validation, reconnect error mapping, local binding removal, Play-services availability, Calendar/Tasks repository reuse, and no Android desktop OAuth imports are implemented and contract-tested. | | BLOCKED | Google — live | Two disposable Google accounts, enabled Calendar/Tasks APIs, and Android OAuth registrations for the debug, local-release and Play app-signing certificate fingerprints are required to exercise consent/cancel/deny/expiry/reconnect, real data, removal, and foreground/headless silent acquisition. | -| PASSED | Microsoft — implementation/contract | MSAL multiple-account browser flow, exact native-account binding, provider-reported grant persistence, account-specific silent acquisition, domain reconnect mapping, independent reminder date/time editing, and removal isolation are implemented and contract-tested. | +| PASSED | Microsoft — implementation/contract | MSAL multiple-account browser flow, exact native-account binding, provider-reported grant persistence, account-specific silent acquisition, domain reconnect mapping, independent reminder date/time editing, and removal isolation are implemented. Native unit tests distinguish interaction-required from service/network/configuration failures; live account recovery is not implied. | | BLOCKED | Microsoft — live | A real public client ID/tenant, signer-specific debug/local/Play redirects, disposable personal and organizational accounts, and an Android device are required for consent/browser return/silent worker/removal tests. | -| PASSED | Nextcloud — automated | Existing DAV engines, Login Flow v2 state, capabilities, pending operations, conflict behavior, collection administration and Android contextual-LAN mapping pass shared/unit coverage. Android UI consumes concrete collection capabilities and exposes supported sharing/publishing/trash/restore plus advanced VTODO/hierarchy/export operations. | +| PASSED | Nextcloud — automated | Existing shared tests cover DAV engines, Login Flow v2 state, capabilities, pending operations, conflicts, and collection administration. Android source exposes capability-gated sharing/publishing/trash/restore, scheduling inbox, guest availability, invitation reply, advanced VTODO/hierarchy, and export routes. Current Android widget coverage verifies route presence and error-safe composition; it does not claim a live Nextcloud round trip. | | BLOCKED | Nextcloud — live | A disposable public-HTTPS server account plus an owner-configured LAN endpoint on an Android 17 device are required to verify polling/resume, app-password storage, real calendars/tasks/sharing/publishing/trash/admin and denied-LAN behavior. | | PASSED | Apple and WebCal — automated | Calendar-only/read-only capability enforcement, credential separation, subscription refresh state and meaningful account failure state use the existing tested repositories and Android mobile UI. | | BLOCKED | Apple and WebCal — live | Disposable iCloud credentials with an app-specific password and disposable WebCal endpoints are required to verify revocation, refresh, and private embedded credentials against real services. | -| PASSED | Calendar and tasks — automated | Virtual Agenda, time-axis Day/Week with all-day/overlap/current-time placement, civil-date Month, and Year miniatures are implemented. Populated Android fixtures verify overnight intersection and source/list hiding. Shared and Android tests cover all-day/timed/date-only items, recurrence/exceptions, due/undated/completed tasks, fields, hierarchy, read-only capabilities, account-qualified destinations, editor discard/recovery behavior, and empty/busy/error states. | +| PASSED | Calendar and tasks — automated | Virtual Agenda, time-axis Day/Week with bounded accessible all-day overflow, scrollable civil-date Month with selected-day agenda, and Year miniatures are implemented. Current populated Android fixtures verify overnight intersection, source/list hiding, Week selection, invitation controls, all-day overflow, and short/large-text Month/Year. Editor tests cover read-only controls, account-qualified destinations, every named text field's system-Back guard, versioned recovery identity/recurrence/scope, and dirty task moves. Shared provider payload tests remain a separate evidence layer. | | BLOCKED | Calendar and tasks — service round trips | Named destructive workflows for Google, Microsoft, Nextcloud, Apple and WebCal require provider registrations and dedicated QA collections. No owner's real collection was modified. | | PASSED | Offline and conflicts — automated | Cached browsing, supported queued mutations, replay/backoff, ETags, tombstones, preserved ICS properties, interrupted/ambiguous creates and serialized UI/worker account access are covered by the shared suite and account gate. | | BLOCKED | Offline and conflicts — real network | Provider-side state after a dropped create response and reconnect must be observed using disposable remote collections and controlled networking on an Android device. | | PASSED | Database and preferences — automated | Schema migration through version 18, foreground/background visibility, atomic settings writes, account removal, engine-owned coordination release and account resurrection protections are covered by database, settings, sync and native gate tests. | | BLOCKED | Database and preferences — OS interruption | Kill-during-write, upgrade, and account removal while a real WorkManager job owns the native process require an installed app and device instrumentation. | -| PASSED | Notifications — automated | Durable mapping, horizon/cap, exact/inexact policy, generation/stale-action rejection, edit/cancel/dedup, explicit Open UI behavior, Snooze/Dismiss persistence, post-quiet-hours eligibility, pending overdue inexact retention, privacy/precision replacement, private/summary logic, first-reminder permission request and an offline-capable refill worker are covered by deterministic tests/source contracts. Future scheduling is not labeled delivery. | +| PASSED | Notifications — automated | Durable mappings and stateful reconciliation tests distinguish pending, displayed, dismissed, and stale-generation notifications. They cover quiet-hours deferral, overdue inexact retention, Snooze/Dismiss, explicit Open UI behavior, and overdue daily-summary privacy/precision replacement. Other shared tests cover horizon/cap and mutation-driven rebuilds. Platform delivery, permission recovery, and refill lifecycle remain device rows; future scheduling is not labeled delivery. | | BLOCKED | Notifications — delivered behavior | A physical phone and signed installed build are required for screen-off timing, process death, permission/channel denial/revocation, reboot, package upgrade, battery idle, exact/inexact observation and cold/warm actions. | | PASSED | Time and recurrence — automated | Shared timezone, DST gap/overlap, all-day/date-only/floating fields, recurrence and exception tests pass; Android system timezone/12–24-hour channel updates feed the app scope. | | BLOCKED | Time and recurrence — system changes | A device is required to change clock/timezone/locale/12–24-hour settings and verify alarm rescheduling across provider/device timezone differences. | @@ -128,8 +132,8 @@ provider account, or owner registration. | BLOCKED | Rotation/split screen/background/foreground | Widget constraints passed, but the real Activity lifecycle needs an Android device. | | BLOCKED | Ordinary process death/reboot | WorkManager, alarm restoration and database invalidation must be observed after real process removal and reboot. | | BLOCKED | Force-stop | A device is required. Expected behavior is explicitly limited: Android does not guarantee work or alarms after user force-stop until the app is started again. | -| PASSED | Logical viewport rendering | The automated adaptive matrix covers all required logical sizes, themes, scales and RTL across each primary destination without overflow. | -| PASSED | Automated accessibility | Controls have semantic labels/tooltips, primary touch targets are at least 48 dp, large text is not clamped, and color is not the sole source/account/error indicator in Android widget tests. | +| PASSED | Logical viewport rendering | The empty-shell matrix covers required logical sizes, themes, scales, RTL, and every primary destination. The populated matrix separately covers Day/Week data and short 320x500/200% Month/Year without framework exceptions. This is logical widget rendering, not a device screenshot. | +| PASSED | Automated accessibility | Widget tests verify labelled navigation and accessible all-day overflow, and exercise large text without clamping. Touch-target and color/contrast statements remain source/widget checks rather than TalkBack or physical-device certification. | | BLOCKED | Actual Android screenshots | No Android device existed, so no device screenshots were produced. Linux screenshots are intentionally not presented as Android evidence. Required scenes are mixed schedule, selected-day month agenda, timed overlaps, tasks with undated/completed items, keyboard-visible event editor, account selection, settings, and loading/error/offline states using QA data. | | BLOCKED | TalkBack/traversal/contrast | Requires a device or emulator with Android accessibility services and manual observation at all named screens. | | BLOCKED | Attached keyboard/touch alternatives/predictive Back | Requires an Android Activity and input devices; verify draft preservation and route-first Back behavior. | diff --git a/docs/provider_support_matrix.md b/docs/provider_support_matrix.md index a3399f04..526e206c 100644 --- a/docs/provider_support_matrix.md +++ b/docs/provider_support_matrix.md @@ -36,6 +36,14 @@ DAV-backed events preserve data BusyMax does not edit, including recurrence exceptions, alarms, timezones, parameters, and provider extensions. See the [iCalendar and DAV data model](icalendar_data_model.md). +On Android, supported invitation responses are visible from an invitation's +Schedule detail sheet. Google and capability-enabled Nextcloud guest +availability is visible in the event editor after at least one guest and a +valid interval are present. Microsoft availability is intentionally absent +because it is not part of the provider contract. A capability-enabled +Nextcloud calendar exposes its scheduling inbox from **Settings > calendar > +Scheduling inbox**. + ## Task objects | Capability | Google Tasks | Microsoft To Do | Nextcloud Tasks | diff --git a/lib/src/android/android_notifications.dart b/lib/src/android/android_notifications.dart index 55549f65..cf5bd60d 100644 --- a/lib/src/android/android_notifications.dart +++ b/lib/src/android/android_notifications.dart @@ -28,6 +28,125 @@ final androidNotificationServiceProvider = Provider( ), ); +/// Testable boundary between reminder policy/database reconciliation and the +/// process-global notifications plugin. +abstract interface class AndroidNotificationBackend { + Future initialize({ + required InitializationSettings settings, + DidReceiveNotificationResponseCallback? onDidReceiveNotificationResponse, + DidReceiveBackgroundNotificationResponseCallback? + onDidReceiveBackgroundNotificationResponse, + }); + + Future getNotificationAppLaunchDetails(); + Future> pendingNotificationRequests(); + Future> getActiveNotifications(); + Future cancel({required int id}); + Future show({ + required int id, + String? title, + String? body, + required NotificationDetails notificationDetails, + }); + Future zonedSchedule({ + required int id, + String? title, + String? body, + required tz.TZDateTime scheduledDate, + required NotificationDetails notificationDetails, + required AndroidScheduleMode androidScheduleMode, + String? payload, + }); + Future requestNotificationPermission(); + Future requestExactAlarmPermission(); + Future canScheduleExactly(); +} + +final class FlutterAndroidNotificationBackend + implements AndroidNotificationBackend { + FlutterAndroidNotificationBackend([FlutterLocalNotificationsPlugin? plugin]) + : _plugin = plugin ?? FlutterLocalNotificationsPlugin(); + + final FlutterLocalNotificationsPlugin _plugin; + + AndroidFlutterLocalNotificationsPlugin? get _android => _plugin + .resolvePlatformSpecificImplementation< + AndroidFlutterLocalNotificationsPlugin + >(); + + @override + Future initialize({ + required InitializationSettings settings, + DidReceiveNotificationResponseCallback? onDidReceiveNotificationResponse, + DidReceiveBackgroundNotificationResponseCallback? + onDidReceiveBackgroundNotificationResponse, + }) => _plugin.initialize( + settings: settings, + onDidReceiveNotificationResponse: onDidReceiveNotificationResponse, + onDidReceiveBackgroundNotificationResponse: + onDidReceiveBackgroundNotificationResponse, + ); + + @override + Future getNotificationAppLaunchDetails() => + _plugin.getNotificationAppLaunchDetails(); + + @override + Future> pendingNotificationRequests() => + _plugin.pendingNotificationRequests(); + + @override + Future> getActiveNotifications() => + _plugin.getActiveNotifications(); + + @override + Future cancel({required int id}) => _plugin.cancel(id: id); + + @override + Future show({ + required int id, + String? title, + String? body, + required NotificationDetails notificationDetails, + }) => _plugin.show( + id: id, + title: title, + body: body, + notificationDetails: notificationDetails, + ); + + @override + Future zonedSchedule({ + required int id, + String? title, + String? body, + required tz.TZDateTime scheduledDate, + required NotificationDetails notificationDetails, + required AndroidScheduleMode androidScheduleMode, + String? payload, + }) => _plugin.zonedSchedule( + id: id, + title: title, + body: body, + scheduledDate: scheduledDate, + notificationDetails: notificationDetails, + androidScheduleMode: androidScheduleMode, + payload: payload, + ); + + @override + Future requestNotificationPermission() async => + await _android?.requestNotificationsPermission() ?? true; + + @override + Future requestExactAlarmPermission() async => + await _android?.requestExactAlarmsPermission() ?? false; + + @override + Future canScheduleExactly() async => + await _android?.canScheduleExactNotifications() ?? false; +} + @pragma('vm:entry-point') Future busyMaxNotificationBackgroundResponse( NotificationResponse response, @@ -184,17 +303,21 @@ final class AndroidNotificationService BusyMaxAndroidPlatform? platform, AndroidNotificationStrings Function()? strings, FlutterLocalNotificationsPlugin? plugin, + AndroidNotificationBackend? backend, + Future Function()? exactAlarmCapability, }) : _database = database, _settings = settings, _platform = platform ?? BusyMaxAndroidPlatform.instance, _strings = strings ?? _defaultStrings, - _plugin = plugin ?? FlutterLocalNotificationsPlugin(); + _notifications = backend ?? FlutterAndroidNotificationBackend(plugin), + _exactAlarmCapability = exactAlarmCapability; final AppDatabase _database; final AppSettings Function() _settings; final BusyMaxAndroidPlatform _platform; final AndroidNotificationStrings Function() _strings; - final FlutterLocalNotificationsPlugin _plugin; + final AndroidNotificationBackend _notifications; + final Future Function()? _exactAlarmCapability; final StreamController _activations = StreamController.broadcast(sync: true); AndroidReminderActivation? _initialActivation; @@ -219,7 +342,7 @@ final class AndroidNotificationService } tz_data.initializeTimeZones(); updateTimeZone(timeZoneId); - await _plugin.initialize( + await _notifications.initialize( settings: const InitializationSettings( android: AndroidInitializationSettings('ic_notification'), ), @@ -227,7 +350,7 @@ final class AndroidNotificationService onDidReceiveBackgroundNotificationResponse: busyMaxNotificationBackgroundResponse, ); - final launch = await _plugin.getNotificationAppLaunchDetails(); + final launch = await _notifications.getNotificationAppLaunchDetails(); if (launch?.didNotificationLaunchApp ?? false) { _initialActivation = _publicActivation( _AndroidReminderPayload.tryParse(launch?.notificationResponse?.payload), @@ -245,27 +368,17 @@ final class AndroidNotificationService } Future requestNotificationPermission() async { - final android = _plugin - .resolvePlatformSpecificImplementation< - AndroidFlutterLocalNotificationsPlugin - >(); - return await android?.requestNotificationsPermission() ?? true; + return _notifications.requestNotificationPermission(); } Future requestExactAlarmPermission() async { - final android = _plugin - .resolvePlatformSpecificImplementation< - AndroidFlutterLocalNotificationsPlugin - >(); - return await android?.requestExactAlarmsPermission() ?? false; + return _notifications.requestExactAlarmPermission(); } Future canScheduleExactly() async { - final android = _plugin - .resolvePlatformSpecificImplementation< - AndroidFlutterLocalNotificationsPlugin - >(); - return await android?.canScheduleExactNotifications() ?? false; + final override = _exactAlarmCapability; + if (override != null) return override(); + return _notifications.canScheduleExactly(); } @override @@ -301,7 +414,7 @@ final class AndroidNotificationService }) async { if (!_initialized) return; final strings = _strings(); - await _plugin.show( + await _notifications.show( id: id, title: title, body: body, @@ -360,9 +473,24 @@ final class AndroidNotificationService for (final mapping in mappings) mapping.scheduleId: mapping, }; final pendingIds = { - for (final request in await _plugin.pendingNotificationRequests()) + for (final request in await _notifications.pendingNotificationRequests()) request.id, }; + var activeStateKnown = true; + Set activeIds; + try { + activeIds = { + for (final notification + in await _notifications.getActiveNotifications()) + if (notification.id case final id?) id, + }; + } on Object { + // Losing visibility into posted notifications must not make a routine + // reconciliation dismiss them. Source/generation invalidation below is + // still authoritative and will cancel an obsolete registration. + activeStateKnown = false; + activeIds = const {}; + } final exact = await canScheduleExactly(); final registrationState = androidNotificationRegistrationState( settings: settings, @@ -384,13 +512,19 @@ final class AndroidNotificationService } final effectiveAt = applyAndroidQuietHours(_effectiveAt(row), settings); final mapping = mappingByScheduleId[row.id]; + final mappingMatchesGeneration = mapping?.generation == row.generation; final remainsPending = - mapping != null && pendingIds.contains(mapping.platformId); + mappingMatchesGeneration && pendingIds.contains(mapping!.platformId); + final remainsDisplayed = + mappingMatchesGeneration && + (activeIds.contains(mapping!.platformId) || + (!activeStateKnown && !remainsPending)); return !shouldKeepAndroidReminder( effectiveAt: effectiveAt, now: nowMillis, horizon: horizon, remainsPending: remainsPending, + remainsDisplayed: remainsDisplayed, ); }); rows.sort( @@ -404,7 +538,7 @@ final class AndroidNotificationService for (final mapping in mappings) { if (!desiredIds.contains(mapping.scheduleId)) { - await _plugin.cancel(id: mapping.platformId); + await _notifications.cancel(id: mapping.platformId); await (_database.delete( _database.androidNotificationMappings, )..where((table) => table.scheduleId.equals(mapping.scheduleId))).go(); @@ -431,14 +565,23 @@ final class AndroidNotificationService for (final row in desired) { final effectiveAt = applyAndroidQuietHours(_effectiveAt(row), settings); var mapping = activeMappings[row.id]; - final unchanged = + final sameGeneration = mapping?.generation == row.generation; + final displayed = + sameGeneration && + (activeIds.contains(mapping!.platformId) || + (!activeStateKnown && + !pendingIds.contains(mapping.platformId) && + effectiveAt <= nowMillis)); + final unchangedPending = mapping != null && - mapping.generation == row.generation && + sameGeneration && mapping.scheduledAtUtc == effectiveAt && mapping.state == registrationState && pendingIds.contains(mapping.platformId); - if (unchanged) continue; - if (mapping != null) await _plugin.cancel(id: mapping.platformId); + if (displayed || unchangedPending) continue; + if (mapping != null) { + await _notifications.cancel(id: mapping.platformId); + } final platformId = mapping?.platformId ?? allocateAndroidNotificationId(row.id, usedIds); usedIds.add(platformId); @@ -453,7 +596,7 @@ final class AndroidNotificationService final scheduledAt = effectiveAt <= nowMillis ? now.add(const Duration(seconds: 5)).millisecondsSinceEpoch : effectiveAt; - await _plugin.zonedSchedule( + await _notifications.zonedSchedule( id: platformId, title: settings.notificationDetailLevel == NotificationDetailLevel.private @@ -507,6 +650,7 @@ final class AndroidNotificationService exact: exact, registrationState: registrationState, pendingIds: pendingIds, + activeIds: activeIds, ); } @@ -516,13 +660,14 @@ final class AndroidNotificationService required bool exact, required String registrationState, required Set pendingIds, + required Set activeIds, }) async { const platformId = 0x425903; final existing = await _database .select(_database.androidDailySummarySchedules) .get(); if (!settings.notifyDueToday || eligibleAccounts.isEmpty) { - await _plugin.cancel(id: platformId); + await _notifications.cancel(id: platformId); await _database.delete(_database.androidDailySummarySchedules).go(); return; } @@ -530,7 +675,7 @@ final class AndroidNotificationService final localDate = '${now.year.toString().padLeft(4, '0')}-${now.month.toString().padLeft(2, '0')}-${now.day.toString().padLeft(2, '0')}'; for (final row in existing.where((row) => row.localDate != localDate)) { - await _plugin.cancel(id: row.platformId); + await _notifications.cancel(id: row.platformId); await (_database.delete( _database.androidDailySummarySchedules, )..where((table) => table.localDate.equals(row.localDate))).go(); @@ -572,7 +717,7 @@ final class AndroidNotificationService due.day == now.day; }).length; if (count == 0) { - await _plugin.cancel(id: platformId); + await _notifications.cancel(id: platformId); await (_database.delete( _database.androidDailySummarySchedules, )..where((table) => table.localDate.equals(localDate))).go(); @@ -581,11 +726,28 @@ final class AndroidNotificationService final current = existing .where((row) => row.localDate == localDate) .firstOrNull; - if (current != null && - current.scheduledAtUtc <= DateTime.now().millisecondsSinceEpoch) { - // Android does not report ordinary display delivery. Keeping this - // registration prevents a false delivery claim and duplicate summary. - return; + final nowMillis = DateTime.now().millisecondsSinceEpoch; + if (current != null) { + final pending = pendingIds.contains(platformId); + final displayed = activeIds.contains(platformId); + final contentMatches = current.taskCount == count; + if (displayed && contentMatches) { + // A posted summary remains actionable. Do not remove or repost it just + // because its scheduled time has elapsed or policy has since changed. + return; + } + if (pending && contentMatches && current.state == registrationState) { + // Inexact delivery may be late. Preserve an unchanged pending alarm, + // but policy/content changes below must replace it. + return; + } + if (current.scheduledAtUtc <= nowMillis && !pending && !displayed) { + // Absence from both platform sets after the due time can mean the user + // dismissed an already shown summary. Do not infer that it needs to be + // emitted again. If active-state inspection failed, prefer the same + // non-destructive outcome. + return; + } } var scheduled = tz.TZDateTime(tz.local, now.year, now.month, now.day, 9); if (!scheduled.isAfter(now)) { @@ -602,10 +764,10 @@ final class AndroidNotificationService current.state == registrationState && pendingIds.contains(platformId); if (unchanged) return; - if (current != null) await _plugin.cancel(id: platformId); + if (current != null) await _notifications.cancel(id: platformId); final generation = '$localDate:$count:$scheduledAt'; final strings = _strings(); - await _plugin.zonedSchedule( + await _notifications.zonedSchedule( id: platformId, title: strings.dueTodayTitle, body: settings.notificationDetailLevel == NotificationDetailLevel.private @@ -747,7 +909,10 @@ bool shouldKeepAndroidReminder({ required int now, required int horizon, required bool remainsPending, -}) => effectiveAt <= horizon && (effectiveAt > now || remainsPending); + bool remainsDisplayed = false, +}) => + effectiveAt <= horizon && + (effectiveAt > now || remainsPending || remainsDisplayed); bool _isQuietNow(AppSettings settings) { final now = DateTime.now().millisecondsSinceEpoch; diff --git a/lib/src/android/presentation/android_availability_dialog.dart b/lib/src/android/presentation/android_availability_dialog.dart new file mode 100644 index 00000000..93a12255 --- /dev/null +++ b/lib/src/android/presentation/android_availability_dialog.dart @@ -0,0 +1,214 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:intl/intl.dart'; + +import '../../app/app_bootstrap.dart'; +import '../../calendar_providers/calendar_sync_dto.dart'; +import '../../core/logging/redacting_logger.dart'; +import '../../dav/nextcloud/nextcloud_scheduling_service.dart'; +import '../../features/calendar/presentation/event_editor_draft.dart'; +import '../../l10n/l10n.dart'; +import '../../l10n/time_format_scope.dart'; +import '../../providers/busy_provider.dart'; + +Future showAndroidGuestAvailabilityDialog( + BuildContext context, { + required String accountId, + required BusyProvider provider, + required EventEditorDraft draft, + String? collectionId, +}) => showDialog( + context: context, + builder: (_) => AndroidGuestAvailabilityDialog( + accountId: accountId, + provider: provider, + collectionId: collectionId, + draft: draft, + ), +); + +class AndroidGuestAvailabilityDialog extends ConsumerStatefulWidget { + const AndroidGuestAvailabilityDialog({ + super.key, + required this.accountId, + required this.provider, + required this.draft, + this.collectionId, + }); + + final String accountId; + final BusyProvider provider; + final String? collectionId; + final EventEditorDraft draft; + + @override + ConsumerState createState() => + _AndroidGuestAvailabilityDialogState(); +} + +class _AndroidGuestAvailabilityDialogState + extends ConsumerState { + bool _loading = true; + Object? _error; + List _nextcloud = const []; + Map> _google = const {}; + + List get _recipients => widget.draft.attendees + .where((attendee) => !attendee.self && !attendee.organizer) + .map((attendee) => attendee.email.trim()) + .where((email) => email.isNotEmpty) + .toSet() + .toList(growable: false); + + @override + void initState() { + super.initState(); + unawaited(_load()); + } + + Future _load() async { + if (!_loading && mounted) setState(() => _loading = true); + _error = null; + try { + if (widget.provider == BusyProvider.nextcloud) { + final collectionId = widget.collectionId; + if (collectionId == null) throw StateError('Missing collection'); + _nextcloud = await ref + .read(nextcloudSchedulingServiceProvider(widget.accountId)) + .freeBusyForDraft( + collectionId: collectionId, + draft: widget.draft, + fallbackTimeZone: ref.read(localTimeZoneProvider), + ); + } else if (widget.provider == BusyProvider.google) { + final client = ref.read( + calendarRemoteApiClientForAccountProvider(widget.accountId), + ); + final start = widget.draft.start; + final end = widget.draft.end; + if (client == null || start == null || end == null) { + throw StateError('Availability is unavailable'); + } + final slots = await client.freeBusy( + calendarIds: _recipients, + rangeStart: start.toUtc(), + rangeEnd: end.toUtc(), + ); + _google = { + for (final recipient in _recipients) + recipient: slots + .where( + (slot) => + slot.calendarId.toLowerCase() == recipient.toLowerCase(), + ) + .toList(growable: false), + }; + } else { + throw StateError('Availability is unsupported'); + } + } on Object catch (error) { + _error = error; + } finally { + if (mounted) setState(() => _loading = false); + } + } + + @override + Widget build(BuildContext context) { + final date = DateFormat.yMMMd(Localizations.localeOf(context).toString()); + String interval(DateTime start, DateTime end) => + '${formatClockDateTime(context, start.toLocal(), date.format(start.toLocal()))} – ' + '${formatClockDateTime(context, end.toLocal(), date.format(end.toLocal()))}'; + return AlertDialog( + key: const ValueKey('android-guest-availability-dialog'), + title: Text(context.l10n.nextcloudGuestAvailability), + content: SizedBox( + width: 520, + height: (MediaQuery.sizeOf(context).height * .55).clamp(180, 520), + child: _loading + ? const Center(child: CircularProgressIndicator()) + : _error != null + ? SingleChildScrollView(child: Text(redactForLog(_error))) + : ListView( + children: [ + for (final result in _nextcloud) + _AvailabilityEntry( + recipient: result.recipient.replaceFirst( + RegExp(r'^mailto:', caseSensitive: false), + '', + ), + free: + result.availability != + NextcloudAvailability.unknown && + result.intervals.isEmpty, + unknown: + result.availability == NextcloudAvailability.unknown, + intervals: [ + for (final value in result.intervals) + interval(value.startUtc, value.endUtc), + ], + ), + for (final entry in _google.entries) + _AvailabilityEntry( + recipient: entry.key, + free: entry.value.isEmpty, + unknown: false, + intervals: [ + for (final value in entry.value) + interval(value.start, value.end), + ], + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: _loading ? null : _load, + child: Text(context.l10n.refresh), + ), + FilledButton( + onPressed: () => Navigator.pop(context), + child: Text(context.l10n.close), + ), + ], + ); + } +} + +class _AvailabilityEntry extends StatelessWidget { + const _AvailabilityEntry({ + required this.recipient, + required this.free, + required this.unknown, + required this.intervals, + }); + + final String recipient; + final bool free; + final bool unknown; + final List intervals; + + @override + Widget build(BuildContext context) => Padding( + padding: const EdgeInsets.only(bottom: 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SelectableText( + recipient, + style: Theme.of(context).textTheme.titleSmall, + ), + Text( + unknown + ? context.l10n.nextcloudAvailabilityUnknown + : free + ? context.l10n.nextcloudAvailabilityFree + : context.l10n.nextcloudAvailabilityBusy, + ), + for (final value in intervals) SelectableText(value), + ], + ), + ); +} diff --git a/lib/src/android/presentation/android_diagnostics_screen.dart b/lib/src/android/presentation/android_diagnostics_screen.dart new file mode 100644 index 00000000..71b17b5f --- /dev/null +++ b/lib/src/android/presentation/android_diagnostics_screen.dart @@ -0,0 +1,189 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../app/app_bootstrap.dart'; +import '../../core/logging/redacting_logger.dart'; +import '../../db/app_database.dart'; +import '../../l10n/l10n.dart'; +import '../../providers/busy_provider.dart'; +import '../android_background.dart'; +import '../android_notifications.dart'; + +class AndroidDiagnosticsScreen extends ConsumerWidget { + const AndroidDiagnosticsScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final accounts = ref.watch(accountsStreamProvider).valueOrNull ?? const []; + final labels = { + for (final account in accounts) account.id: account.displayLabel, + }; + final redact = ref + .watch(appSettingsControllerProvider) + .redactTaskContentInDiagnostics; + return Scaffold( + appBar: AppBar(title: Text(context.l10n.diagnostics)), + body: ListView( + padding: const EdgeInsets.fromLTRB(12, 8, 12, 40), + children: [ + Card( + child: Column( + children: [ + ListTile( + leading: const Icon(Icons.notifications_outlined), + title: Text(context.l10n.reminders), + subtitle: Text( + ref + .read(androidNotificationServiceProvider) + .precisionDiagnostic, + ), + ), + ListTile( + leading: const Icon(Icons.sync), + title: Text(context.l10n.sync), + subtitle: Text(context.l10n.davCachedOfflineNotice), + onTap: () => unawaited(enqueueImmediateBusyMaxSync()), + ), + ], + ), + ), + for (final account in accounts) + Card( + child: ListTile( + leading: const Icon(Icons.account_circle_outlined), + title: Text(account.displayLabel), + subtitle: Text( + '${account.provider.displayName} · ${account.authState}' + '${account.lastSuccessfulSyncAtUtc == null ? '' : '\n${account.lastSuccessfulSyncAtUtc!.toLocal()}'}', + ), + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(4, 20, 4, 8), + child: Text( + context.l10n.blockedPendingOperations, + style: Theme.of(context).textTheme.titleMedium, + ), + ), + StreamBuilder>( + stream: ref + .watch(databaseProvider) + .pendingOpsDao + .watchAllBlockedOps(), + builder: (context, snapshot) { + final operations = snapshot.data ?? const []; + if (operations.isEmpty) { + return ListTile( + leading: const Icon(Icons.check_circle_outline), + title: Text(context.l10n.noBlockedPendingOperations), + ); + } + return Column( + children: [ + for (final operation in operations) + Card( + child: ListTile( + leading: const Icon(Icons.report_problem_outlined), + title: Text( + '${operation.entityType}: ${operation.operation}', + ), + subtitle: Text( + [ + labels[operation.accountId] ?? operation.accountId, + if (operation.taskListId != null) + context.l10n.pendingOpListId( + operation.taskListId!, + ), + context.l10n.pendingOpAttempts( + operation.attemptCount, + ), + if (operation.lastErrorCode != null) + operation.lastErrorCode!, + if (operation.lastErrorMessage != null) + redact + ? redactForLog(operation.lastErrorMessage) + : operation.lastErrorMessage!, + ].join('\n'), + ), + isThreeLine: true, + trailing: PopupMenuButton( + onSelected: (action) => unawaited( + _resolve(context, ref, operation, action), + ), + itemBuilder: (context) => [ + PopupMenuItem( + value: 'retry', + child: Text(context.l10n.retry), + ), + PopupMenuItem( + value: 'discard', + child: Text(context.l10n.discard), + ), + ], + ), + ), + ), + ], + ); + }, + ), + ], + ), + ); + } + + Future _resolve( + BuildContext context, + WidgetRef ref, + PendingOp operation, + String action, + ) async { + final service = ref.read( + pendingOpResolutionServiceForAccountProvider(operation.accountId), + ); + try { + if (action == 'retry') { + await service.retryNow(operation.id); + } else { + final confirmed = await showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: Text(context.l10n.discardPendingOperation), + content: Text(context.l10n.discardPendingOperationConfirmation), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext, false), + child: Text(context.l10n.cancel), + ), + FilledButton( + onPressed: () => Navigator.pop(dialogContext, true), + child: Text(context.l10n.discard), + ), + ], + ), + ); + if (confirmed != true) return; + await service.discard(operation.id); + } + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + action == 'retry' + ? context.l10n.retryCompleted + : context.l10n.pendingOperationDiscarded, + ), + ), + ); + } + } on Object catch (error) { + if (context.mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(redactForLog(error)))); + } + } + } +} diff --git a/lib/src/android/presentation/android_feedback_screen.dart b/lib/src/android/presentation/android_feedback_screen.dart new file mode 100644 index 00000000..a6b3d951 --- /dev/null +++ b/lib/src/android/presentation/android_feedback_screen.dart @@ -0,0 +1,275 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:package_info_plus/package_info_plus.dart'; +import 'package:uuid/uuid.dart'; + +import '../../app/app_bootstrap.dart'; +import '../../features/feedback/data/feedback_api_client.dart'; +import '../../features/feedback/data/feedback_submission.dart'; +import '../../l10n/l10n.dart'; + +class AndroidFeedbackScreen extends ConsumerStatefulWidget { + const AndroidFeedbackScreen({super.key}); + + @override + ConsumerState createState() => + _AndroidFeedbackScreenState(); +} + +class _AndroidFeedbackScreenState extends ConsumerState { + final _subject = TextEditingController(); + final _message = TextEditingController(); + final _email = TextEditingController(); + FeedbackCategory? _category; + bool _technical = false; + bool _submitting = false; + bool _validate = false; + String? _status; + bool _statusError = false; + String _submissionId = const Uuid().v4(); + + bool get _dirty => + _category != null || + _subject.text.trim().isNotEmpty || + _message.text.trim().isNotEmpty || + _email.text.trim().isNotEmpty || + _technical; + + @override + void dispose() { + _subject.dispose(); + _message.dispose(); + _email.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) => PopScope( + canPop: !_dirty && !_submitting, + onPopInvokedWithResult: (didPop, _) async { + if (didPop || _submitting) return; + final discard = await _confirmDiscard(); + if (!mounted || !discard) return; + Navigator.pop(this.context); + }, + child: Scaffold( + appBar: AppBar( + title: Text(context.l10n.sendFeedback), + actions: [ + TextButton( + onPressed: _submitting ? null : _submit, + child: Text(context.l10n.feedbackSubmit), + ), + ], + ), + body: ListView( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 40), + children: [ + DropdownButtonFormField( + initialValue: _category, + decoration: InputDecoration( + labelText: context.l10n.feedbackCategory, + errorText: + _validate && !FeedbackValidation.categoryIsValid(_category) + ? context.l10n.feedbackCategoryRequired + : null, + ), + items: [ + for (final value in FeedbackCategory.values) + DropdownMenuItem( + value: value, + child: Text(_categoryLabel(context, value)), + ), + ], + onChanged: _submitting + ? null + : (value) => setState(() { + _category = value; + _changed(); + }), + ), + const SizedBox(height: 12), + TextField( + key: const ValueKey('android-feedback-subject'), + controller: _subject, + enabled: !_submitting, + decoration: InputDecoration( + labelText: context.l10n.feedbackSubject, + errorText: + _validate && !FeedbackValidation.subjectIsValid(_subject.text) + ? context.l10n.feedbackSubjectLengthError + : null, + ), + onChanged: (_) => setState(_changed), + ), + const SizedBox(height: 12), + TextField( + key: const ValueKey('android-feedback-message'), + controller: _message, + enabled: !_submitting, + minLines: 5, + maxLines: 10, + decoration: InputDecoration( + labelText: context.l10n.feedbackDetailedMessage, + alignLabelWithHint: true, + errorText: + _validate && !FeedbackValidation.messageIsValid(_message.text) + ? context.l10n.feedbackMessageLengthError + : null, + ), + onChanged: (_) => setState(_changed), + ), + const SizedBox(height: 12), + TextField( + controller: _email, + enabled: !_submitting, + keyboardType: TextInputType.emailAddress, + decoration: InputDecoration( + labelText: context.l10n.feedbackReplyEmail, + errorText: + _validate && + !FeedbackValidation.replyEmailIsValid(_email.text) + ? context.l10n.feedbackInvalidEmail + : null, + ), + onChanged: (_) => setState(_changed), + ), + const SizedBox(height: 8), + SwitchListTile( + contentPadding: EdgeInsets.zero, + value: _technical, + title: Text(context.l10n.feedbackIncludeTechnicalDetails), + subtitle: Text(context.l10n.feedbackTechnicalDetailsDisclosure), + onChanged: _submitting + ? null + : (value) => setState(() { + _technical = value; + _changed(); + }), + ), + if (_submitting) const LinearProgressIndicator(), + if (_status case final value?) + Semantics( + liveRegion: true, + child: Padding( + padding: const EdgeInsets.only(top: 12), + child: Text( + value, + style: TextStyle( + color: _statusError + ? Theme.of(context).colorScheme.error + : Theme.of(context).colorScheme.primary, + ), + ), + ), + ), + ], + ), + ), + ); + + void _changed() { + if (_statusError) _submissionId = const Uuid().v4(); + _status = null; + _statusError = false; + } + + Future _submit() async { + final category = _category; + final valid = + FeedbackValidation.categoryIsValid(category) && + FeedbackValidation.subjectIsValid(_subject.text) && + FeedbackValidation.messageIsValid(_message.text) && + FeedbackValidation.replyEmailIsValid(_email.text); + setState(() => _validate = true); + if (!valid || category == null) return; + final locale = Localizations.localeOf(context).toLanguageTag(); + setState(() => _submitting = true); + try { + final package = await PackageInfo.fromPlatform(); + final receipt = await ref + .read(feedbackSubmissionServiceProvider) + .submit( + FeedbackSubmission( + submissionId: _submissionId, + appVersion: package.version.trim().isEmpty + ? 'unknown' + : package.version, + buildNumber: package.buildNumber.trim().isEmpty + ? '0' + : package.buildNumber, + category: category, + subject: _subject.text, + message: _message.text, + replyEmail: _email.text, + platform: Platform.operatingSystem, + technicalDetails: _technical + ? FeedbackTechnicalDetails( + osVersion: Platform.operatingSystemVersion, + locale: locale, + ) + : null, + ), + ); + if (!mounted) return; + _subject.clear(); + _message.clear(); + _email.clear(); + setState(() { + _category = null; + _technical = false; + _validate = false; + _status = context.l10n.feedbackSuccess(receipt.id); + _statusError = false; + _submissionId = const Uuid().v4(); + }); + } on Object catch (error) { + if (!mounted) return; + setState(() { + _status = switch (error) { + FeedbackConnectionFailure() => context.l10n.feedbackConnectionError, + FeedbackTimeoutFailure() => context.l10n.feedbackTimeoutError, + FeedbackRateLimitedFailure() => context.l10n.feedbackRateLimitedError, + FeedbackRejectedFailure() => context.l10n.feedbackRejectedError, + FeedbackServerFailure() => context.l10n.feedbackServerError, + _ => context.l10n.feedbackServerError, + }; + _statusError = true; + }); + } finally { + if (mounted) setState(() => _submitting = false); + } + } + + Future _confirmDiscard() async => + await showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: Text(context.l10n.discardChanges), + content: Text(context.l10n.discardChangesConfirmation), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext, false), + child: Text(context.l10n.cancel), + ), + FilledButton( + onPressed: () => Navigator.pop(dialogContext, true), + child: Text(context.l10n.discardChangesAction), + ), + ], + ), + ) ?? + false; +} + +String _categoryLabel(BuildContext context, FeedbackCategory category) => + switch (category) { + FeedbackCategory.problem => context.l10n.feedbackCategoryProblem, + FeedbackCategory.feature => context.l10n.feedbackCategoryFeature, + FeedbackCategory.privacySecurity => + context.l10n.feedbackCategoryPrivacySecurity, + FeedbackCategory.usability => context.l10n.feedbackCategoryUsability, + FeedbackCategory.other => context.l10n.feedbackCategoryOther, + }; diff --git a/lib/src/android/presentation/android_nextcloud_scheduling_screen.dart b/lib/src/android/presentation/android_nextcloud_scheduling_screen.dart new file mode 100644 index 00000000..41790255 --- /dev/null +++ b/lib/src/android/presentation/android_nextcloud_scheduling_screen.dart @@ -0,0 +1,134 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../app/app_bootstrap.dart'; +import '../../dav/dav_errors.dart'; +import '../../dav/nextcloud/nextcloud_scheduling_controller.dart'; +import '../../dav/nextcloud/nextcloud_scheduling_service.dart'; +import '../../l10n/l10n.dart'; + +class AndroidNextcloudSchedulingScreen extends ConsumerStatefulWidget { + const AndroidNextcloudSchedulingScreen({ + super.key, + required this.accountId, + required this.collectionId, + }); + + final String accountId; + final String collectionId; + + @override + ConsumerState createState() => + _AndroidNextcloudSchedulingScreenState(); +} + +class _AndroidNextcloudSchedulingScreenState + extends ConsumerState { + late final NextcloudSchedulingController _model; + + @override + void initState() { + super.initState(); + _model = NextcloudSchedulingController( + ref.read(nextcloudSchedulingServiceProvider(widget.accountId)), + widget.collectionId, + fallbackTimeZone: ref.read(localTimeZoneProvider), + )..addListener(_changed); + unawaited(_model.load()); + } + + void _changed() { + if (mounted) setState(() {}); + } + + @override + void dispose() { + _model.removeListener(_changed); + _model.dispose(); + super.dispose(); + } + + Future _acknowledge(NextcloudInboxMessage message) async { + final confirmed = await showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: Text(context.l10n.nextcloudAcknowledge), + content: Text(context.l10n.nextcloudAcknowledgeConfirm), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext, false), + child: Text(context.l10n.cancel), + ), + FilledButton( + onPressed: () => Navigator.pop(dialogContext, true), + child: Text(context.l10n.nextcloudAcknowledge), + ), + ], + ), + ); + if (confirmed == true && mounted) await _model.acknowledge(message); + } + + @override + Widget build(BuildContext context) { + final error = _model.error; + return Scaffold( + appBar: AppBar( + title: Text(context.l10n.nextcloudSchedulingInbox), + actions: [ + IconButton( + tooltip: context.l10n.refresh, + onPressed: _model.busy ? null : _model.load, + icon: const Icon(Icons.refresh), + ), + ], + ), + body: RefreshIndicator( + onRefresh: _model.load, + child: ListView( + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.fromLTRB(16, 12, 16, 40), + children: [ + Text(context.l10n.nextcloudInboxExplanation), + if (_model.busy) ...[ + const SizedBox(height: 16), + const LinearProgressIndicator(), + ], + if (error != null) ...[ + const SizedBox(height: 16), + Text( + error is DavException && + error.kind == DavErrorKind.authorization + ? context.l10n.nextcloudOperationDenied + : context.l10n.nextcloudServerUnavailable, + ), + ], + if (_model.loaded && + !_model.busy && + error == null && + _model.messages.isEmpty) + Padding( + padding: const EdgeInsets.only(top: 24), + child: Text(context.l10n.nextcloudInboxEmpty), + ), + for (final message in _model.messages) + Card( + child: ListTile( + title: Text(message.title), + subtitle: message.method == null + ? null + : Text(message.method!), + trailing: TextButton( + onPressed: _model.busy ? null : () => _acknowledge(message), + child: Text(context.l10n.nextcloudAcknowledge), + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/src/android/presentation/android_schedule_screen.dart b/lib/src/android/presentation/android_schedule_screen.dart index 27c3f29c..3eefc8a4 100644 --- a/lib/src/android/presentation/android_schedule_screen.dart +++ b/lib/src/android/presentation/android_schedule_screen.dart @@ -8,6 +8,8 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:intl/intl.dart'; import '../../app/app_bootstrap.dart'; +import '../../calendar_providers/calendar_mutation.dart'; +import '../../core/logging/redacting_logger.dart'; import '../../features/accounts/data/accounts_repository.dart'; import '../../features/calendar/data/calendar_repository.dart'; import '../../features/calendar/presentation/event_editor_draft.dart'; @@ -25,13 +27,14 @@ import '../../schedule/schedule_projection.dart'; import '../../schedule/schedule_range.dart'; import '../../schedule/schedule_view_mode.dart'; import '../android_notifications.dart'; +import 'android_availability_dialog.dart'; import 'android_settings_screen.dart'; import 'android_tasks_screen.dart'; final _androidScheduleItemsProvider = FutureProvider.autoDispose .family< List, - ({DateTime anchor, ScheduleViewMode mode, String query}) + ({DateTime anchor, ScheduleViewMode mode, String query, int firstWeekday}) >((ref, key) async { ref.watch(scheduleDataRevisionProvider); final sources = await ref.watch(calendarSourcesStreamProvider.future); @@ -40,7 +43,11 @@ final _androidScheduleItemsProvider = FutureProvider.autoDispose return ref .watch(scheduleRepositoryProvider) .listItems( - range: _rangeFor(key.anchor, key.mode), + range: _rangeFor( + key.anchor, + key.mode, + firstWeekday: key.firstWeekday, + ), filters: ScheduleFilters( query: key.query, sourceIds: { @@ -88,6 +95,7 @@ class _AndroidScheduleScreenState extends ConsumerState { anchor: DateTime(_anchor.year, _anchor.month, _anchor.day), mode: mode, query: _query, + firstWeekday: _firstWeekday(context), )), ); return Scaffold( @@ -418,6 +426,52 @@ class _AndroidScheduleScreenState extends ConsumerState { padding: const EdgeInsets.only(top: 12), child: Text(notes), ), + if (item is CalendarScheduleItem && + item.canRespondToInvitation) ...[ + const SizedBox(height: 16), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + FilledButton.tonal( + onPressed: () { + Navigator.pop(sheetContext); + unawaited( + _respondToInvitation( + item, + CalendarInvitationResponse.accept, + ), + ); + }, + child: Text(context.l10n.acceptInvitation), + ), + OutlinedButton( + onPressed: () { + Navigator.pop(sheetContext); + unawaited( + _respondToInvitation( + item, + CalendarInvitationResponse.tentative, + ), + ); + }, + child: Text(context.l10n.tentativeInvitation), + ), + OutlinedButton( + onPressed: () { + Navigator.pop(sheetContext); + unawaited( + _respondToInvitation( + item, + CalendarInvitationResponse.decline, + ), + ); + }, + child: Text(context.l10n.declineInvitation), + ), + ], + ), + ], const SizedBox(height: 16), if (item.capabilities.canEdit) FilledButton.icon( @@ -544,6 +598,55 @@ class _AndroidScheduleScreenState extends ConsumerState { await showAndroidEventEditor(context, ref, eventId: item.id); } + Future _respondToInvitation( + CalendarScheduleItem item, + CalendarInvitationResponse response, + ) async { + if (!item.canRespondToInvitation) return; + try { + RecurringEventMutationScope? scope; + if (item.provider == BusyProvider.nextcloud && + item.providerRecurringEventId != null) { + scope = await showModalBottomSheet( + context: context, + showDragHandle: true, + builder: (sheetContext) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile(title: Text(context.l10n.chooseRecurringEventScope)), + for (final value in RecurringEventMutationScope.values) + if (value != RecurringEventMutationScope.thisAndFuture) + ListTile( + title: Text(_scopeLabel(context, value)), + onTap: () => Navigator.pop(sheetContext, value), + ), + ], + ), + ), + ); + if (scope == null || !mounted) return; + } + final accountId = await ref + .read(calendarRepositoryProvider) + .respondToLocalEvent(item.id, response, recurringScope: scope); + ref + .read( + pendingCalendarMutationSyncRequesterForAccountProvider(accountId), + ) + .request(); + } on Object catch (error) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + context.l10n.invitationResponseFailed(redactForLog(error)), + ), + ), + ); + } + } + Future _toggleTask(TaskScheduleItem task) async { if (!task.capabilities.canEdit) return; try { @@ -628,6 +731,7 @@ class _ScheduleBody extends StatelessWidget { key: ValueKey('android-${mode.name}-time-grid'), anchor: anchor, days: mode == ScheduleViewMode.day ? 1 : 7, + firstWeekday: _firstWeekday(context), items: items, onOpen: onOpen, ), @@ -729,6 +833,7 @@ class _AndroidTimeGrid extends StatelessWidget { super.key, required this.anchor, required this.days, + required this.firstWeekday, required this.items, required this.onOpen, }); @@ -738,6 +843,7 @@ class _AndroidTimeGrid extends StatelessWidget { final DateTime anchor; final int days; + final int firstWeekday; final List items; final ValueChanged onOpen; @@ -745,7 +851,7 @@ class _AndroidTimeGrid extends StatelessWidget { Widget build(BuildContext context) { final start = days == 1 ? DateTime(anchor.year, anchor.month, anchor.day) - : ScheduleRange.week(anchor).start; + : ScheduleRange.week(anchor, firstWeekday: firstWeekday).start; final dates = [ for (var index = 0; index < days; index++) DateTime(start.year, start.month, start.day + index), @@ -788,7 +894,8 @@ class _AndroidTimeGrid extends StatelessWidget { ), ), SizedBox( - height: 58, + height: (MediaQuery.textScalerOf(context).scale(28) + 70) + .clamp(92.0, 148.0), child: Row( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ @@ -891,14 +998,53 @@ class _AndroidAllDayCell extends StatelessWidget { children: [ _AndroidCalendarChip(item: items.first, onOpen: onOpen), if (items.length > 1) - Text( - '+${items.length - 1}', - textAlign: TextAlign.end, - style: Theme.of(context).textTheme.labelSmall, + Align( + alignment: AlignmentDirectional.centerEnd, + child: TextButton( + style: TextButton.styleFrom( + minimumSize: const Size(48, 32), + padding: const EdgeInsets.symmetric(horizontal: 4), + textStyle: Theme.of(context).textTheme.labelSmall, + ), + onPressed: () => _showAllDayItems(context), + child: Text(context.l10n.moreItems(items.length - 1)), + ), ), ], ), ); + + Future _showAllDayItems(BuildContext context) => + showModalBottomSheet( + context: context, + showDragHandle: true, + isScrollControlled: true, + builder: (sheetContext) => SafeArea( + child: FractionallySizedBox( + heightFactor: .62, + child: ListView.builder( + padding: const EdgeInsets.fromLTRB(12, 0, 12, 24), + itemCount: items.length, + itemBuilder: (context, index) { + final item = items[index]; + return ListTile( + leading: Icon( + item is TaskScheduleItem + ? Icons.check_circle_outline + : Icons.event_outlined, + ), + title: Text(item.title), + subtitle: Text(item.sourceName ?? item.provider.displayName), + onTap: () { + Navigator.pop(sheetContext); + onOpen(item); + }, + ); + }, + ), + ), + ), + ); } class _AndroidTimedDayColumn extends StatelessWidget { @@ -1095,6 +1241,12 @@ class _MonthView extends StatelessWidget { Widget build(BuildContext context) { return LayoutBuilder( builder: (context, constraints) { + final scaledDayText = MediaQuery.textScalerOf(context).scale(16); + // The grid is inside the outer vertical scroller, so preserve the + // user's text scale with real civil-day row height instead of + // shrinking full-size Month labels to fit a viewport fraction. + final gridHeight = 30 + 6 * (scaledDayText * 1.55 + 36); + final agendaHeight = (constraints.maxHeight * .42).clamp(200.0, 420.0); return GestureDetector( behavior: HitTestBehavior.opaque, onHorizontalDragEnd: (details) { @@ -1104,30 +1256,32 @@ class _MonthView extends StatelessWidget { DateTime(anchor.year, anchor.month + (velocity < 0 ? 1 : -1)), ); }, - child: Column( - children: [ - Expanded( - flex: 3, - child: Padding( - padding: const EdgeInsets.fromLTRB(8, 8, 8, 0), - child: _AndroidMonthGrid( - key: const ValueKey('android-month-grid'), - displayedMonth: DateTime(anchor.year, anchor.month), - selectedDate: anchor, - items: items, - onDaySelected: onSelectDate, + child: SingleChildScrollView( + child: Column( + children: [ + SizedBox( + height: gridHeight, + child: Padding( + padding: const EdgeInsets.fromLTRB(8, 8, 8, 0), + child: _AndroidMonthGrid( + key: const ValueKey('android-month-grid'), + displayedMonth: DateTime(anchor.year, anchor.month), + selectedDate: anchor, + items: items, + onDaySelected: onSelectDate, + ), ), ), - ), - Expanded( - flex: 2, - child: _AgendaList( - items: ScheduleProjection.itemsForDay(items, anchor), - onOpen: onOpen, - onToggleTask: onToggleTask, + SizedBox( + height: agendaHeight, + child: _AgendaList( + items: ScheduleProjection.itemsForDay(items, anchor), + onOpen: onOpen, + onToggleTask: onToggleTask, + ), ), - ), - ], + ], + ), ), ); }, @@ -1216,8 +1370,11 @@ class _AndroidMonthGrid extends StatelessWidget { Widget build(BuildContext context) { final firstWeekday = _firstWeekday(context); final month = DateTime(displayedMonth.year, displayedMonth.month); - final firstOffset = (month.weekday - firstWeekday) % DateTime.daysPerWeek; - final gridStart = DateTime(month.year, month.month, 1 - firstOffset); + final range = androidMonthGridRange( + displayedMonth, + firstWeekday: firstWeekday, + ); + final gridStart = range.start; final days = [ for (var index = 0; index < 42; index++) DateTime(gridStart.year, gridStart.month, gridStart.day + index), @@ -1293,6 +1450,44 @@ class _AndroidMonthDay extends StatelessWidget { final matching = ScheduleProjection.itemsForDay(items, day); final selected = _sameDay(day, selectedDate); final scheme = Theme.of(context).colorScheme; + final content = Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + '${day.day}', + style: + (compact + ? Theme.of(context).textTheme.labelSmall + : Theme.of(context).textTheme.bodyMedium) + ?.copyWith( + color: day.month == displayedMonth.month + ? null + : scheme.onSurfaceVariant.withValues(alpha: .45), + ), + ), + if (matching.isNotEmpty) + Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + for (final item in matching.take(compact ? 2 : 3)) + Container( + width: compact ? 3 : 5, + height: compact ? 3 : 5, + margin: const EdgeInsets.symmetric(horizontal: 1), + decoration: BoxDecoration( + shape: BoxShape.circle, + color: ScheduleProjection.colorForItem( + item, + Theme.of(context).brightness, + ), + ), + ), + ], + ), + ], + ); return InkWell( onTap: () => onSelected(day), child: DecoratedBox( @@ -1302,42 +1497,9 @@ class _AndroidMonthDay extends StatelessWidget { ? null : Border.all(color: Theme.of(context).dividerColor, width: .35), ), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - '${day.day}', - style: - (compact - ? Theme.of(context).textTheme.labelSmall - : Theme.of(context).textTheme.bodyMedium) - ?.copyWith( - color: day.month == displayedMonth.month - ? null - : scheme.onSurfaceVariant.withValues(alpha: .45), - ), - ), - if (matching.isNotEmpty) - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - for (final item in matching.take(compact ? 2 : 3)) - Container( - width: compact ? 3 : 5, - height: compact ? 3 : 5, - margin: const EdgeInsets.symmetric(horizontal: 1), - decoration: BoxDecoration( - shape: BoxShape.circle, - color: ScheduleProjection.colorForItem( - item, - Theme.of(context).brightness, - ), - ), - ), - ], - ), - ], - ), + child: compact + ? FittedBox(fit: BoxFit.scaleDown, child: content) + : Center(child: content), ), ); } @@ -1385,6 +1547,8 @@ class _AndroidEventEditorState extends ConsumerState { bool _saving = false; bool _allowPop = false; bool _recoveryLoaded = false; + bool _recoveryWritesBlocked = false; + Future _recoveryIo = Future.value(); Timer? _recoveryTimer; String get _recoveryKey => @@ -1454,7 +1618,7 @@ class _AndroidEventEditorState extends ConsumerState { onPopInvokedWithResult: (didPop, _) async { if (didPop || _saving || _allowPop) return; if (await _confirmDiscardChanges() && mounted) { - await _clearRecovery(); + await _clearRecovery(finalCleanup: true); if (!mounted) return; setState(() => _allowPop = true); WidgetsBinding.instance.addPostFrameCallback((_) { @@ -1518,6 +1682,8 @@ class _AndroidEventEditorState extends ConsumerState { accountId: source.accountId, sourceId: source.id, providerCalendarId: source.providerCalendarId, + clearShowAs: true, + clearVisibilityOrSensitivity: true, ), ); }, @@ -1552,6 +1718,54 @@ class _AndroidEventEditorState extends ConsumerState { setState(() => _draft = _draft.copyWith(end: value)), ), const SizedBox(height: 12), + DropdownButtonFormField( + key: ValueKey('android-event-show-as-${_provider.name}'), + initialValue: + _eventShowAsValues(_provider).contains(_draft.showAs) + ? _draft.showAs + : _eventShowAsValues(_provider).first, + decoration: InputDecoration( + labelText: context.l10n.availabilityShowAs, + ), + items: [ + for (final value in _eventShowAsValues(_provider)) + DropdownMenuItem( + value: value, + child: Text(_eventAvailabilityLabel(context, value)), + ), + ], + onChanged: !_canEdit + ? null + : (value) => setState( + () => _draft = _draft.copyWith(showAs: value), + ), + ), + const SizedBox(height: 12), + DropdownButtonFormField( + key: ValueKey('android-event-visibility-${_provider.name}'), + initialValue: + _eventVisibilityValues( + _provider, + ).contains(_draft.visibilityOrSensitivity) + ? _draft.visibilityOrSensitivity + : _eventVisibilityValues(_provider).first, + decoration: InputDecoration(labelText: context.l10n.visibility), + items: [ + for (final value in _eventVisibilityValues(_provider)) + DropdownMenuItem( + value: value, + child: Text(_eventVisibilityLabel(context, value)), + ), + ], + onChanged: !_canEdit + ? null + : (value) => setState( + () => _draft = _draft.copyWith( + visibilityOrSensitivity: value, + ), + ), + ), + const SizedBox(height: 12), TextFormField( controller: _location, enabled: _canEdit, @@ -1559,7 +1773,8 @@ class _AndroidEventEditorState extends ConsumerState { labelText: context.l10n.location, prefixIcon: const Icon(Icons.place_outlined), ), - onChanged: (v) => _draft = _draft.copyWith(location: v), + onChanged: (v) => + setState(() => _draft = _draft.copyWith(location: v)), ), const SizedBox(height: 12), TextFormField( @@ -1570,7 +1785,8 @@ class _AndroidEventEditorState extends ConsumerState { decoration: InputDecoration( labelText: context.l10n.description, ), - onChanged: (v) => _draft = _draft.copyWith(description: v), + onChanged: (v) => + setState(() => _draft = _draft.copyWith(description: v)), ), const SizedBox(height: 12), DropdownButtonFormField( @@ -1644,12 +1860,25 @@ class _AndroidEventEditorState extends ConsumerState { enabled: _canEdit && _draft.canManageAttendees, decoration: InputDecoration(labelText: context.l10n.guests), keyboardType: TextInputType.emailAddress, + onChanged: (_) => setState(() {}), ), + if (_canCheckGuestAvailability) ...[ + const SizedBox(height: 4), + Align( + alignment: AlignmentDirectional.centerStart, + child: TextButton.icon( + onPressed: _showGuestAvailability, + icon: const Icon(Icons.event_available_outlined), + label: Text(context.l10n.nextcloudGuestAvailability), + ), + ), + ], const SizedBox(height: 12), TextFormField( controller: _categories, enabled: _canEdit, decoration: InputDecoration(labelText: context.l10n.categories), + onChanged: (_) => setState(() {}), ), if (_canDelete) ...[ const SizedBox(height: 24), @@ -1739,7 +1968,7 @@ class _AndroidEventEditorState extends ConsumerState { ) .request(); if (mounted) { - await _clearRecovery(); + await _clearRecovery(finalCleanup: true); if (!mounted) return; setState(() => _allowPop = true); Navigator.pop(context); @@ -1755,6 +1984,41 @@ class _AndroidEventEditorState extends ConsumerState { } } + bool get _canCheckGuestAvailability { + final source = _source; + if (source == null || + _draft.start == null || + _draft.end == null || + !_draft.end!.isAfter(_draft.start!)) { + return false; + } + final hasGuests = mergeAndroidEventAttendees( + _draft.attendees, + _guests.text, + ).attendees.any((attendee) => !attendee.self && !attendee.organizer); + if (!hasGuests) return false; + if (source.provider == BusyProvider.google) return true; + return source.provider == BusyProvider.nextcloud && + source.davCollectionId != null && + source.davEffectivePermissions['canQueryFreeBusy'] == true; + } + + Future _showGuestAvailability() async { + final source = _source; + if (source == null || !_canCheckGuestAvailability) return; + final attendees = mergeAndroidEventAttendees( + _draft.attendees, + _guests.text, + ).attendees; + await showAndroidGuestAvailabilityDialog( + context, + accountId: source.accountId, + provider: source.provider, + collectionId: source.davCollectionId, + draft: _draft.copyWith(attendees: attendees), + ); + } + Future _delete() async { var draft = _draft; if (draft.providerRecurringEventId != null && @@ -1798,7 +2062,7 @@ class _AndroidEventEditorState extends ConsumerState { ) .request(); if (mounted) { - await _clearRecovery(); + await _clearRecovery(finalCleanup: true); if (!mounted) return; setState(() => _allowPop = true); Navigator.pop(context); @@ -1848,7 +2112,7 @@ class _AndroidEventEditorState extends ConsumerState { Future _requestClose() async { if (!_hasPendingEdits || await _confirmDiscardChanges()) { if (!mounted) return; - await _clearRecovery(); + await _clearRecovery(finalCleanup: true); if (!mounted) return; setState(() => _allowPop = true); Navigator.pop(context); @@ -1892,9 +2156,31 @@ class _AndroidEventEditorState extends ConsumerState { } final start = DateTime.tryParse(map['start']?.toString() ?? ''); final end = DateTime.tryParse(map['end']?.toString() ?? ''); + if (map['v'] != 2 || start == null || end == null) { + await _clearRecovery(); + return; + } + final recoveredSource = widget.sources + .where( + (source) => + source.id == map['sourceId'] && + source.accountId == map['accountId'] && + source.providerCalendarId == map['providerCalendarId'] && + (widget.draft.eventId == null + ? source.capabilities.canCreateEvents + : source.capabilities.canEditEvents), + ) + .firstOrNull; + if (recoveredSource == null) { + await _clearRecovery(); + return; + } final frequency = RecurrenceFrequency.values .where((value) => value.name == map['frequency']) .firstOrNull; + final recurringScope = RecurringEventMutationScope.values + .where((value) => value.name == map['recurringMutationScope']) + .firstOrNull; if (!mounted) return; await WidgetsBinding.instance.endOfFrame; if (!mounted) return; @@ -1932,12 +2218,21 @@ class _AndroidEventEditorState extends ConsumerState { _recurrenceChanged = true; } _draft = _draft.copyWith( + accountId: recoveredSource.accountId, + sourceId: recoveredSource.id, + providerCalendarId: recoveredSource.providerCalendarId, title: _title.text, location: _location.text, description: _description.text, allDay: map['allDay'] as bool? ?? _draft.allDay, start: start, end: end, + recurringMutationScope: recurringScope, + clearRecurringMutationScope: recurringScope == null, + showAs: map['showAs']?.toString(), + clearShowAs: map['showAs'] == null, + visibilityOrSensitivity: map['visibility']?.toString(), + clearVisibilityOrSensitivity: map['visibility'] == null, ); }); } on Object { @@ -1948,32 +2243,57 @@ class _AndroidEventEditorState extends ConsumerState { } Future _persistRecovery() async { - if (!_recoveryLoaded || !_hasPendingEdits || _allowPop) return; - await ref - .read(secureStorageProvider) - .write( - key: _recoveryKey, - value: jsonEncode({ - 'v': 1, - 'savedAt': DateTime.now().toIso8601String(), - 'baseTitle': widget.draft.title, - 'baseStart': widget.draft.start?.toIso8601String(), - 'title': _title.text, - 'location': _location.text, - 'description': _description.text, - 'guests': _guests.text, - 'categories': _categories.text, - 'allDay': _draft.allDay, - 'start': _draft.start?.toIso8601String(), - 'end': _draft.end?.toIso8601String(), - 'reminderMinutes': _reminderMinutes, - 'frequency': _frequency.name, - }), - ); + if (!_recoveryLoaded || + !_hasPendingEdits || + _allowPop || + _recoveryWritesBlocked) { + return; + } + final payload = jsonEncode({ + 'v': 2, + 'savedAt': DateTime.now().toIso8601String(), + 'baseTitle': widget.draft.title, + 'baseStart': widget.draft.start?.toIso8601String(), + 'accountId': _draft.accountId, + 'sourceId': _draft.sourceId, + 'providerCalendarId': _draft.providerCalendarId, + 'recurringMutationScope': _draft.recurringMutationScope?.name, + 'showAs': _draft.showAs, + 'visibility': _draft.visibilityOrSensitivity, + 'title': _title.text, + 'location': _location.text, + 'description': _description.text, + 'guests': _guests.text, + 'categories': _categories.text, + 'allDay': _draft.allDay, + 'start': _draft.start?.toIso8601String(), + 'end': _draft.end?.toIso8601String(), + 'reminderMinutes': _reminderMinutes, + 'frequency': _frequency.name, + }); + await _enqueueRecovery(() async { + if (_recoveryWritesBlocked) return; + await ref + .read(secureStorageProvider) + .write(key: _recoveryKey, value: payload); + }); + } + + Future _clearRecovery({bool finalCleanup = false}) { + if (finalCleanup) { + _recoveryWritesBlocked = true; + _recoveryTimer?.cancel(); + } + return _enqueueRecovery( + () => ref.read(secureStorageProvider).delete(key: _recoveryKey), + ); } - Future _clearRecovery() => - ref.read(secureStorageProvider).delete(key: _recoveryKey); + Future _enqueueRecovery(Future Function() operation) { + final result = _recoveryIo.then((_) => operation()); + _recoveryIo = result.catchError((_) {}); + return result.catchError((_) {}); + } } final class AndroidEventAttendeeEdit { @@ -2142,17 +2462,56 @@ class _Message extends StatelessWidget { ); } -ScheduleRange _rangeFor(DateTime anchor, ScheduleViewMode mode) => - switch (mode) { - ScheduleViewMode.day => ScheduleRange.day(anchor), - ScheduleViewMode.week => ScheduleRange.week(anchor), - ScheduleViewMode.month => ScheduleRange.month(anchor), - ScheduleViewMode.year => ScheduleRange.year(anchor), - ScheduleViewMode.agenda => ScheduleRange( - start: DateTime(anchor.year, anchor.month, anchor.day - 30), - end: DateTime(anchor.year, anchor.month, anchor.day + 91), - ), - }; +ScheduleRange _rangeFor( + DateTime anchor, + ScheduleViewMode mode, { + required int firstWeekday, +}) => switch (mode) { + ScheduleViewMode.day => ScheduleRange.day(anchor), + ScheduleViewMode.week => ScheduleRange.week( + anchor, + firstWeekday: firstWeekday, + ), + ScheduleViewMode.month => androidMonthGridRange( + anchor, + firstWeekday: firstWeekday, + ), + ScheduleViewMode.year => androidYearGridRange( + anchor, + firstWeekday: firstWeekday, + ), + ScheduleViewMode.agenda => ScheduleRange( + start: DateTime(anchor.year, anchor.month, anchor.day - 30), + end: DateTime(anchor.year, anchor.month, anchor.day + 91), + ), +}; + +/// The exact civil-date interval rendered by the Android six-week month grid. +ScheduleRange androidMonthGridRange( + DateTime month, { + required int firstWeekday, +}) { + final first = DateTime(month.year, month.month); + final offset = (first.weekday - firstWeekday) % DateTime.daysPerWeek; + final start = DateTime(first.year, first.month, first.day - offset); + return ScheduleRange( + start: start, + end: DateTime(start.year, start.month, start.day + 42), + ); +} + +/// Covers every leading and trailing date drawn by all twelve year miniatures. +ScheduleRange androidYearGridRange(DateTime year, {required int firstWeekday}) { + final january = androidMonthGridRange( + DateTime(year.year), + firstWeekday: firstWeekday, + ); + final december = androidMonthGridRange( + DateTime(year.year, DateTime.december), + firstWeekday: firstWeekday, + ); + return ScheduleRange(start: january.start, end: december.end); +} DateTime _move(DateTime date, ScheduleViewMode mode, int amount) => switch (mode) { @@ -2178,8 +2537,8 @@ String _periodLabel( ScheduleViewMode.day || ScheduleViewMode.agenda => DateFormat.yMMMMd().format(date), ScheduleViewMode.week => - '${DateFormat.MMMd().format(ScheduleRange.week(date).start)} – ' - '${DateFormat.MMMd().format(_previousCivilDay(ScheduleRange.week(date).end))}', + '${DateFormat.MMMd().format(ScheduleRange.week(date, firstWeekday: _firstWeekday(context)).start)} – ' + '${DateFormat.MMMd().format(_previousCivilDay(ScheduleRange.week(date, firstWeekday: _firstWeekday(context)).end))}', ScheduleViewMode.month => DateFormat.yMMMM().format(date), ScheduleViewMode.year => '${date.year}', }; @@ -2215,3 +2574,34 @@ Object? _eventReminders(BusyProvider provider, int minutes) { ], }; } + +List _eventShowAsValues(BusyProvider provider) => + provider == BusyProvider.microsoft + ? const ['free', 'tentative', 'busy', 'oof', 'workingElsewhere'] + : const ['opaque', 'transparent']; + +String _eventAvailabilityLabel(BuildContext context, String value) => + switch (value) { + 'opaque' || 'busy' => context.l10n.busy, + 'transparent' || 'free' => context.l10n.availabilityFree, + 'tentative' => context.l10n.availabilityTentative, + 'oof' => context.l10n.availabilityOutOfOffice, + 'workingElsewhere' => context.l10n.availabilityWorkingElsewhere, + _ => value, + }; + +List _eventVisibilityValues(BusyProvider provider) => + provider == BusyProvider.microsoft + ? const ['normal', 'personal', 'private', 'confidential'] + : const ['default', 'public', 'private', 'confidential']; + +String _eventVisibilityLabel(BuildContext context, String value) => + switch (value) { + 'default' => context.l10n.visibilityDefault, + 'public' => context.l10n.visibilityPublic, + 'private' => context.l10n.visibilityPrivate, + 'confidential' => context.l10n.visibilityConfidential, + 'normal' => context.l10n.sensitivityNormal, + 'personal' => context.l10n.sensitivityPersonal, + _ => value, + }; diff --git a/lib/src/android/presentation/android_settings_screen.dart b/lib/src/android/presentation/android_settings_screen.dart index 6f3c881e..1d1dadf4 100644 --- a/lib/src/android/presentation/android_settings_screen.dart +++ b/lib/src/android/presentation/android_settings_screen.dart @@ -29,6 +29,9 @@ import '../../providers/busy_provider.dart'; import '../../webcal/webcal_subscription_service.dart'; import '../android_background.dart'; import '../android_notifications.dart'; +import 'android_diagnostics_screen.dart'; +import 'android_feedback_screen.dart'; +import 'android_nextcloud_scheduling_screen.dart'; class AndroidSettingsScreen extends ConsumerStatefulWidget { const AndroidSettingsScreen({super.key}); @@ -490,6 +493,15 @@ class _AndroidSettingsScreenState extends ConsumerState { subtitle: Text(context.l10n.launchAtLoginDescription), onTap: () => unawaited(enqueueImmediateBusyMaxSync()), ), + ListTile( + leading: const Icon(Icons.monitor_heart_outlined), + title: Text(context.l10n.diagnostics), + onTap: () => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => const AndroidDiagnosticsScreen(), + ), + ), + ), ], ), _Section( @@ -551,6 +563,19 @@ class _AndroidSettingsScreenState extends ConsumerState { subtitle: Text(context.l10n.aboutBusyMaxDescription), onTap: _showAbout, ), + ListTile( + leading: const Icon(Icons.feedback_outlined), + title: Text(context.l10n.sendFeedback), + enabled: ref + .read(buildConfigProvider) + .feedbackEndpoint + .isNotEmpty, + onTap: () => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => const AndroidFeedbackScreen(), + ), + ), + ), ], ), ], @@ -590,7 +615,7 @@ class _AndroidSettingsScreenState extends ConsumerState { ); if (server == null || !mounted) return; final uri = Uri.tryParse(server); - if (uri != null && isAndroidLocalNetworkHost(uri.host)) { + if (uri != null && await requiresAndroidLocalNetworkAccess(uri.host)) { try { final granted = await BusyMaxAndroidPlatform.instance .requestLocalNetworkAccess(); @@ -651,58 +676,11 @@ class _AndroidSettingsScreenState extends ConsumerState { } } - Future<(String, String)?> _appleCredentials(String? fixedEmail) async { - final email = TextEditingController(text: fixedEmail); - final password = TextEditingController(); - final result = await showDialog<(String, String)>( + Future<(String, String)?> _appleCredentials(String? fixedEmail) { + return showDialog<(String, String)>( context: context, - builder: (dialogContext) => AlertDialog( - title: Text(context.l10n.connectAppleICloudTitle), - content: Column( - mainAxisSize: MainAxisSize.min, - children: [ - TextField( - controller: email, - enabled: fixedEmail == null, - keyboardType: TextInputType.emailAddress, - decoration: InputDecoration( - labelText: context.l10n.appleAccountEmail, - ), - ), - const SizedBox(height: 12), - TextField( - controller: password, - obscureText: true, - decoration: InputDecoration( - labelText: context.l10n.appleAppSpecificPassword, - helperText: context.l10n.appleAppSpecificPasswordHelp, - ), - ), - ], - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(dialogContext), - child: Text(context.l10n.cancel), - ), - FilledButton( - onPressed: () { - if (email.text.trim().isNotEmpty && - password.text.trim().isNotEmpty) { - Navigator.pop(dialogContext, ( - email.text.trim(), - password.text.trim(), - )); - } - }, - child: Text(context.l10n.connectAccountAction), - ), - ], - ), + builder: (_) => _AndroidAppleCredentialsDialog(fixedEmail: fixedEmail), ); - email.dispose(); - password.dispose(); - return result; } Future _removeAccount(AccountEntity account) async { @@ -819,6 +797,13 @@ class _AndroidSettingsScreenState extends ConsumerState { title: Text(context.l10n.reminders), onTap: () => Navigator.pop(context, 'reminders'), ), + if (source.provider == BusyProvider.nextcloud && + source.davCollectionId != null) + ListTile( + leading: const Icon(Icons.inbox_outlined), + title: Text(context.l10n.nextcloudSchedulingInbox), + onTap: () => Navigator.pop(context, 'scheduling'), + ), if (source.provider == BusyProvider.nextcloud && source.davCollectionId != null) ListTile( @@ -857,6 +842,7 @@ class _AndroidSettingsScreenState extends ConsumerState { await ref .read(calendarRepositoryProvider) .renameLocalSource(source.id, name); + _requestQueuedCalendarSync(source); } } else if (action == 'reminders') { final reminders = await _booleanPrompt( @@ -873,6 +859,7 @@ class _AndroidSettingsScreenState extends ConsumerState { await ref .read(calendarRepositoryProvider) .setSourceColor(source.id, choice); + _requestQueuedCalendarSync(source); } else if (action == 'export') { await _exportCollection(source.accountId, source.davCollectionId!); } else if (action == 'sharing') { @@ -886,6 +873,16 @@ class _AndroidSettingsScreenState extends ConsumerState { ), ), ); + } else if (action == 'scheduling') { + if (!mounted) return; + await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => AndroidNextcloudSchedulingScreen( + accountId: source.accountId, + collectionId: source.davCollectionId!, + ), + ), + ); } else if (action == 'delete') { final remove = source.capabilities.removalMode == @@ -899,6 +896,7 @@ class _AndroidSettingsScreenState extends ConsumerState { ); if (!confirmed) return; await ref.read(calendarRepositoryProvider).deleteLocalSource(source.id); + _requestQueuedCalendarSync(source); } } on Object catch (error) { _message(l10n.calendarUpdateFailed('$error')); @@ -1134,6 +1132,20 @@ class _AndroidSettingsScreenState extends ConsumerState { ); } + void _requestQueuedCalendarSync(CalendarSourceEntity source) { + if (source.provider != BusyProvider.google && + source.provider != BusyProvider.microsoft) { + return; + } + ref + .read( + pendingCalendarMutationSyncRequesterForAccountProvider( + source.accountId, + ), + ) + .request(); + } + Future _exportCollection(String accountId, String collectionId) async { final l10n = context.l10n; final resources = await ref @@ -1343,34 +1355,16 @@ class _AndroidSettingsScreenState extends ConsumerState { required String label, String? initialValue, String? helper, - }) async { - final controller = TextEditingController(text: initialValue); - final value = await showDialog( + }) { + return showDialog( context: context, - builder: (dialogContext) => AlertDialog( - title: Text(title), - content: TextField( - controller: controller, - autofocus: true, - decoration: InputDecoration(labelText: label, helperText: helper), - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(dialogContext), - child: Text(context.l10n.cancel), - ), - FilledButton( - onPressed: () { - final text = controller.text.trim(); - if (text.isNotEmpty) Navigator.pop(dialogContext, text); - }, - child: Text(context.l10n.save), - ), - ], + builder: (_) => _AndroidTextPromptDialog( + title: title, + label: label, + initialValue: initialValue, + helper: helper, ), ); - controller.dispose(); - return value; } Future _booleanPrompt(String title, bool value) { @@ -1451,6 +1445,160 @@ bool isAndroidLocalNetworkHost(String host) { return loopback || uniqueLocal || linkLocal; } +typedef AndroidHostLookup = Future> Function(String host); + +/// Resolves ordinary DNS names before deciding whether Android's contextual +/// local-network permission is required. DNS failure is left to onboarding so +/// it can surface the real connectivity error without an irrelevant prompt. +Future requiresAndroidLocalNetworkAccess( + String host, { + AndroidHostLookup lookup = InternetAddress.lookup, +}) async { + if (isAndroidLocalNetworkHost(host)) return true; + var value = host.trim(); + if (value.startsWith('[') && value.endsWith(']')) { + value = value.substring(1, value.length - 1); + } + if (value.isEmpty || InternetAddress.tryParse(value) != null) return false; + try { + final addresses = await lookup(value); + return addresses.any( + (address) => isAndroidLocalNetworkHost(address.address), + ); + } on Object { + return false; + } +} + +class _AndroidTextPromptDialog extends StatefulWidget { + const _AndroidTextPromptDialog({ + required this.title, + required this.label, + this.initialValue, + this.helper, + }); + + final String title; + final String label; + final String? initialValue; + final String? helper; + + @override + State<_AndroidTextPromptDialog> createState() => + _AndroidTextPromptDialogState(); +} + +class _AndroidTextPromptDialogState extends State<_AndroidTextPromptDialog> { + late final TextEditingController _controller = TextEditingController( + text: widget.initialValue, + ); + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: Text(widget.title), + content: TextField( + controller: _controller, + autofocus: true, + decoration: InputDecoration( + labelText: widget.label, + helperText: widget.helper, + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text(context.l10n.cancel), + ), + FilledButton( + onPressed: () { + final text = _controller.text.trim(); + if (text.isNotEmpty) Navigator.pop(context, text); + }, + child: Text(context.l10n.save), + ), + ], + ); + } +} + +class _AndroidAppleCredentialsDialog extends StatefulWidget { + const _AndroidAppleCredentialsDialog({this.fixedEmail}); + + final String? fixedEmail; + + @override + State<_AndroidAppleCredentialsDialog> createState() => + _AndroidAppleCredentialsDialogState(); +} + +class _AndroidAppleCredentialsDialogState + extends State<_AndroidAppleCredentialsDialog> { + late final TextEditingController _email = TextEditingController( + text: widget.fixedEmail, + ); + final TextEditingController _password = TextEditingController(); + + @override + void dispose() { + _email.dispose(); + _password.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: Text(context.l10n.connectAppleICloudTitle), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField( + controller: _email, + enabled: widget.fixedEmail == null, + keyboardType: TextInputType.emailAddress, + decoration: InputDecoration( + labelText: context.l10n.appleAccountEmail, + ), + onChanged: (_) => setState(() {}), + ), + const SizedBox(height: 12), + TextField( + controller: _password, + obscureText: true, + decoration: InputDecoration( + labelText: context.l10n.appleAppSpecificPassword, + helperText: context.l10n.appleAppSpecificPasswordHelp, + ), + onChanged: (_) => setState(() {}), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text(context.l10n.cancel), + ), + FilledButton( + onPressed: _email.text.trim().isEmpty || _password.text.trim().isEmpty + ? null + : () => Navigator.pop(context, ( + _email.text.trim(), + _password.text.trim(), + )), + child: Text(context.l10n.connectAccountAction), + ), + ], + ); + } +} + class AndroidNextcloudSharingScreen extends ConsumerStatefulWidget { const AndroidNextcloudSharingScreen({ super.key, diff --git a/lib/src/android/presentation/android_tasks_screen.dart b/lib/src/android/presentation/android_tasks_screen.dart index bface4cd..a65c2b5f 100644 --- a/lib/src/android/presentation/android_tasks_screen.dart +++ b/lib/src/android/presentation/android_tasks_screen.dart @@ -448,6 +448,8 @@ class _AndroidTaskEditorState extends ConsumerState { bool _saving = false; bool _allowPop = false; bool _recoveryLoaded = false; + bool _recoveryWritesBlocked = false; + Future _recoveryIo = Future.value(); Timer? _recoveryTimer; late final TaskDetailsDraft _initialDraft = _draft; late RecurrenceFrequency _recurrenceFrequency = _taskRecurrence().frequency; @@ -520,7 +522,7 @@ class _AndroidTaskEditorState extends ConsumerState { onPopInvokedWithResult: (didPop, _) async { if (didPop || _saving || _allowPop) return; if (await _confirmDiscardChanges() && mounted) { - await _clearRecovery(); + await _clearRecovery(finalCleanup: true); if (!mounted) return; setState(() => _allowPop = true); WidgetsBinding.instance.addPostFrameCallback((_) { @@ -561,7 +563,8 @@ class _AndroidTaskEditorState extends ConsumerState { enabled: canWrite, autofocus: widget.task == null, decoration: InputDecoration(labelText: context.l10n.title), - onChanged: (v) => _draft = _draft.copyWith(title: v), + onChanged: (v) => + setState(() => _draft = _draft.copyWith(title: v)), ), const SizedBox(height: 12), TextField( @@ -570,7 +573,8 @@ class _AndroidTaskEditorState extends ConsumerState { minLines: 3, maxLines: 8, decoration: InputDecoration(labelText: context.l10n.notes), - onChanged: (v) => _draft = _draft.copyWith(notes: v), + onChanged: (v) => + setState(() => _draft = _draft.copyWith(notes: v)), ), if (capabilities.supportsDueDate) ListTile( @@ -795,12 +799,14 @@ class _AndroidTaskEditorState extends ConsumerState { controller: _categories, enabled: canWrite, decoration: InputDecoration(labelText: context.l10n.categories), - onChanged: (v) => _draft = _draft.copyWith( - categories: v - .split(',') - .map((e) => e.trim()) - .where((e) => e.isNotEmpty) - .toList(), + onChanged: (v) => setState( + () => _draft = _draft.copyWith( + categories: v + .split(',') + .map((e) => e.trim()) + .where((e) => e.isNotEmpty) + .toList(), + ), ), ), ], @@ -810,7 +816,8 @@ class _AndroidTaskEditorState extends ConsumerState { controller: _location, enabled: canWrite, decoration: InputDecoration(labelText: context.l10n.location), - onChanged: (v) => _draft = _draft.copyWith(location: v), + onChanged: (v) => + setState(() => _draft = _draft.copyWith(location: v)), ), ], if (capabilities.supportsUrl) ...[ @@ -820,7 +827,8 @@ class _AndroidTaskEditorState extends ConsumerState { enabled: canWrite, keyboardType: TextInputType.url, decoration: InputDecoration(labelText: context.l10n.taskUrl), - onChanged: (value) => _draft = _draft.copyWith(taskUrl: value), + onChanged: (value) => + setState(() => _draft = _draft.copyWith(taskUrl: value)), ), ], if (capabilities.supportsClassification) @@ -1448,55 +1456,13 @@ class _AndroidTaskEditorState extends ConsumerState { } Future _save() async { - _draft = _draft.copyWith( - title: _title.text.trim(), - notes: _notes.text, - location: _location.text, - taskUrl: _url.text.trim(), - categories: _categories.text - .split(',') - .map((e) => e.trim()) - .where((e) => e.isNotEmpty) - .toList(), - ); - if (_draft.title.trim().isEmpty || - !_draft.hasValidTaskUrlFor(_capabilities) || - _draft.scheduleIssueFor(_capabilities) != TaskScheduleIssue.none) { - return; - } + setState(_syncDraftFromControllers); + if (!_draftIsValid) return; setState(() => _saving = true); try { - if (_draft.microsoftReminderEnabled || _draft.alarms.isNotEmpty) { - await ref - .read(androidNotificationServiceProvider) - .requestNotificationPermission(); - } - final repository = ref.read( - tasksRepositoryForAccountProvider(widget.accountId), - ); - if (widget.task == null) { - await repository.createTask( - _draft.taskListId, - _draft.toCreateInput( - _capabilities, - localTimeZone: ref.read(localTimeZoneProvider), - ), - ); - } else { - await repository.patchTask( - _draft.taskListId, - _draft.taskId, - TaskPatchInput( - _draft.toPatch( - widget.task!, - _capabilities, - localTimeZone: ref.read(localTimeZoneProvider), - ), - ), - ); - } + await _persistTaskDraft(); if (mounted) { - await _clearRecovery(); + await _clearRecovery(finalCleanup: true); if (!mounted) return; setState(() => _allowPop = true); Navigator.pop(context); @@ -1512,6 +1478,57 @@ class _AndroidTaskEditorState extends ConsumerState { } } + void _syncDraftFromControllers() { + _draft = _draft.copyWith( + title: _title.text.trim(), + notes: _notes.text, + location: _location.text, + taskUrl: _url.text.trim(), + categories: _categories.text + .split(',') + .map((e) => e.trim()) + .where((e) => e.isNotEmpty) + .toList(), + ); + } + + bool get _draftIsValid => + _draft.title.trim().isNotEmpty && + _draft.hasValidTaskUrlFor(_capabilities) && + _draft.scheduleIssueFor(_capabilities) == TaskScheduleIssue.none; + + Future _persistTaskDraft() async { + if (_draft.microsoftReminderEnabled || _draft.alarms.isNotEmpty) { + await ref + .read(androidNotificationServiceProvider) + .requestNotificationPermission(); + } + final repository = ref.read( + tasksRepositoryForAccountProvider(widget.accountId), + ); + if (widget.task == null) { + await repository.createTask( + _draft.taskListId, + _draft.toCreateInput( + _capabilities, + localTimeZone: ref.read(localTimeZoneProvider), + ), + ); + return; + } + await repository.patchTask( + _draft.taskListId, + _draft.taskId, + TaskPatchInput( + _draft.toPatch( + widget.task!, + _capabilities, + localTimeZone: ref.read(localTimeZoneProvider), + ), + ), + ); + } + RecurrenceRule _taskRecurrence() { final creation = _draft.creationRecurrence; if (creation != null) return creation.rule; @@ -1608,7 +1625,7 @@ class _AndroidTaskEditorState extends ConsumerState { .read(tasksRepositoryForAccountProvider(widget.accountId)) .deleteTask(widget.task!.taskListId, widget.task!.id); if (mounted) { - await _clearRecovery(); + await _clearRecovery(finalCleanup: true); if (!mounted) return; setState(() => _allowPop = true); Navigator.pop(context); @@ -1647,12 +1664,22 @@ class _AndroidTaskEditorState extends ConsumerState { } Future _moveToList() async { - final lists = - (await ref - .read(taskListsRepositoryForAccountProvider(widget.accountId)) - .listTaskLists()) - .where((list) => list.id != _draft.taskListId) - .toList(); + late final List lists; + try { + lists = + (await ref + .read(taskListsRepositoryForAccountProvider(widget.accountId)) + .listTaskLists()) + .where((list) => list.id != _draft.taskListId) + .toList(); + } on Object catch (error) { + if (mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('$error'))); + } + return; + } if (!mounted || lists.isEmpty) return; final destination = await selectAndroidTaskList( context, @@ -1663,18 +1690,49 @@ class _AndroidTaskEditorState extends ConsumerState { }, ); if (destination == null || !mounted) return; + setState(_syncDraftFromControllers); + var saveEdits = false; + if (_hasPendingEdits) { + final decision = await showDialog( + context: context, + barrierDismissible: false, + builder: (dialogContext) => AlertDialog( + title: Text(context.l10n.discardChanges), + content: Text(context.l10n.discardChangesConfirmation), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext), + child: Text(context.l10n.cancel), + ), + TextButton( + onPressed: () => Navigator.pop(dialogContext, false), + child: Text(context.l10n.discardChangesAction), + ), + FilledButton( + onPressed: () => Navigator.pop(dialogContext, true), + child: Text(context.l10n.save), + ), + ], + ), + ); + if (decision == null || !mounted) return; + saveEdits = decision; + if (saveEdits && !_draftIsValid) return; + } setState(() => _saving = true); try { - await ref - .read(tasksRepositoryForAccountProvider(widget.accountId)) - .moveTask( - TaskMoveInput( - sourceTaskListId: _draft.taskListId, - destinationTaskListId: destination.id, - taskId: widget.task!.id, - ), - ); - await _clearRecovery(); + final repository = ref.read( + tasksRepositoryForAccountProvider(widget.accountId), + ); + if (saveEdits) await _persistTaskDraft(); + await repository.moveTask( + TaskMoveInput( + sourceTaskListId: _draft.taskListId, + destinationTaskListId: destination.id, + taskId: widget.task!.id, + ), + ); + await _clearRecovery(finalCleanup: true); if (!mounted) return; setState(() => _allowPop = true); Navigator.pop(context); @@ -1739,7 +1797,7 @@ class _AndroidTaskEditorState extends ConsumerState { Future _requestClose() async { if (!_hasPendingEdits || await _confirmDiscardChanges()) { if (!mounted) return; - await _clearRecovery(); + await _clearRecovery(finalCleanup: true); if (!mounted) return; setState(() => _allowPop = true); Navigator.pop(context); @@ -1777,14 +1835,13 @@ class _AndroidTaskEditorState extends ConsumerState { final savedAt = DateTime.tryParse(map['savedAt']?.toString() ?? ''); if (savedAt == null || DateTime.now().difference(savedAt).abs() > const Duration(days: 14) || + map['v'] != 2 || map['baseTitle'] != _initialDraft.title || + map['accountId'] != widget.accountId || map['taskListId'] != _initialDraft.taskListId) { await _clearRecovery(); return; } - final frequency = RecurrenceFrequency.values - .where((value) => value.name == map['frequency']) - .firstOrNull; final alarms = []; for (final rawAlarm in map['alarms'] as List? ?? const []) { if (rawAlarm is Map) { @@ -1796,12 +1853,15 @@ class _AndroidTaskEditorState extends ConsumerState { notes: map['notes']?.toString() ?? _draft.notes, dueDate: map['dueDate'], microsoftDueTime: map['dueTime'], + microsoftDueTimeZone: map['dueTimeZone']?.toString(), microsoftStartDate: map['startDate'], microsoftStartTime: map['startTime'], + microsoftStartTimeZone: map['startTimeZone']?.toString(), microsoftReminderEnabled: map['reminderEnabled'] as bool? ?? _draft.microsoftReminderEnabled, microsoftReminderDate: map['reminderDate'], microsoftReminderTime: map['reminderTime'], + microsoftReminderTimeZone: map['reminderTimeZone']?.toString(), recurrenceJson: map['recurrenceJson'], importance: map['importance']?.toString(), categories: (map['categories'] as List? ?? const []) @@ -1820,14 +1880,26 @@ class _AndroidTaskEditorState extends ConsumerState { hideCompletedSubtasks: map['hideCompletedSubtasks'] as bool?, alarms: alarms, ); - if (frequency != null && widget.task == null) { + RecurrenceRule? creationRule; + final rawCreationRecurrence = map['creationRecurrence']; + if (widget.task == null && rawCreationRecurrence is Map) { + final raw = rawCreationRecurrence; + final creation = raw.cast(); + final provider = BusyProvider.values + .where((value) => value.name == creation['provider']) + .firstOrNull; final base = DateTime.tryParse(recovered.dueDate ?? '') ?? DateTime.now(); + creationRule = RecurrenceRule.fromJson( + creation['rule']?.toString(), + baseDate: base, + ); + if (provider != widget.provider || !creationRule.isSupported) { + await _clearRecovery(); + return; + } recovered = recovered.copyWith( - creationRecurrence: ( - provider: widget.provider, - rule: _simpleTaskRecurrenceRule(frequency, base), - ), + creationRecurrence: (provider: provider!, rule: creationRule), ); } if (!mounted) return; @@ -1858,7 +1930,8 @@ class _AndroidTaskEditorState extends ConsumerState { if (!mounted) return; setState(() { _draft = recovered; - _recurrenceFrequency = frequency ?? _recurrenceFrequency; + _recurrenceFrequency = + creationRule?.frequency ?? _taskRecurrence().frequency; _title.text = recovered.title; _notes.text = recovered.notes; _location.text = recovered.location; @@ -1873,47 +1946,75 @@ class _AndroidTaskEditorState extends ConsumerState { } Future _persistRecovery() async { - if (!_recoveryLoaded || !_hasPendingEdits || _allowPop) return; - await ref - .read(secureStorageProvider) - .write( - key: _recoveryKey, - value: jsonEncode({ - 'v': 1, - 'savedAt': DateTime.now().toIso8601String(), - 'baseTitle': _initialDraft.title, - 'taskListId': _draft.taskListId, - 'title': _draft.title, - 'notes': _draft.notes, - 'dueDate': _draft.dueDate, - 'dueTime': _draft.microsoftDueTime, - 'startDate': _draft.microsoftStartDate, - 'startTime': _draft.microsoftStartTime, - 'reminderEnabled': _draft.microsoftReminderEnabled, - 'reminderDate': _draft.microsoftReminderDate, - 'reminderTime': _draft.microsoftReminderTime, - 'recurrenceJson': _draft.recurrenceJson, - 'frequency': _recurrenceFrequency.name, - 'importance': _draft.importance, - 'categories': _draft.categories, - 'icalPriority': _draft.icalPriority, - 'percentComplete': _draft.percentComplete, - 'taskStatus': _draft.taskStatus, - 'completedDate': _draft.completedDate, - 'completedTime': _draft.completedTime, - 'location': _draft.location, - 'taskUrl': _draft.taskUrl, - 'classification': _draft.classification, - 'pinned': _draft.pinned, - 'hideSubtasks': _draft.hideSubtasks, - 'hideCompletedSubtasks': _draft.hideCompletedSubtasks, - 'alarms': [for (final alarm in _draft.alarms) alarm.toJson()], - }), - ); + if (!_recoveryLoaded || + !_hasPendingEdits || + _allowPop || + _recoveryWritesBlocked) { + return; + } + final creationRecurrence = _draft.creationRecurrence; + final payload = jsonEncode({ + 'v': 2, + 'savedAt': DateTime.now().toIso8601String(), + 'baseTitle': _initialDraft.title, + 'accountId': widget.accountId, + 'taskListId': _draft.taskListId, + 'title': _draft.title, + 'notes': _draft.notes, + 'dueDate': _draft.dueDate, + 'dueTime': _draft.microsoftDueTime, + 'dueTimeZone': _draft.microsoftDueTimeZone, + 'startDate': _draft.microsoftStartDate, + 'startTime': _draft.microsoftStartTime, + 'startTimeZone': _draft.microsoftStartTimeZone, + 'reminderEnabled': _draft.microsoftReminderEnabled, + 'reminderDate': _draft.microsoftReminderDate, + 'reminderTime': _draft.microsoftReminderTime, + 'reminderTimeZone': _draft.microsoftReminderTimeZone, + 'recurrenceJson': _draft.recurrenceJson, + if (creationRecurrence != null) + 'creationRecurrence': { + 'provider': creationRecurrence.provider.name, + 'rule': creationRecurrence.rule.toJsonString(), + }, + 'importance': _draft.importance, + 'categories': _draft.categories, + 'icalPriority': _draft.icalPriority, + 'percentComplete': _draft.percentComplete, + 'taskStatus': _draft.taskStatus, + 'completedDate': _draft.completedDate, + 'completedTime': _draft.completedTime, + 'location': _draft.location, + 'taskUrl': _draft.taskUrl, + 'classification': _draft.classification, + 'pinned': _draft.pinned, + 'hideSubtasks': _draft.hideSubtasks, + 'hideCompletedSubtasks': _draft.hideCompletedSubtasks, + 'alarms': [for (final alarm in _draft.alarms) alarm.toJson()], + }); + await _enqueueRecovery(() async { + if (_recoveryWritesBlocked) return; + await ref + .read(secureStorageProvider) + .write(key: _recoveryKey, value: payload); + }); } - Future _clearRecovery() => - ref.read(secureStorageProvider).delete(key: _recoveryKey); + Future _clearRecovery({bool finalCleanup = false}) { + if (finalCleanup) { + _recoveryWritesBlocked = true; + _recoveryTimer?.cancel(); + } + return _enqueueRecovery( + () => ref.read(secureStorageProvider).delete(key: _recoveryKey), + ); + } + + Future _enqueueRecovery(Future Function() operation) { + final result = _recoveryIo.then((_) => operation()); + _recoveryIo = result.catchError((_) {}); + return result.catchError((_) {}); + } } class _AndroidRelativeAlarmDialog extends StatefulWidget { diff --git a/packages/busymax_android_platform/android/src/main/kotlin/io/busystack/busymax_android_platform/BusymaxAndroidPlatformPlugin.kt b/packages/busymax_android_platform/android/src/main/kotlin/io/busystack/busymax_android_platform/BusymaxAndroidPlatformPlugin.kt index ee5337c8..980f15f1 100644 --- a/packages/busymax_android_platform/android/src/main/kotlin/io/busystack/busymax_android_platform/BusymaxAndroidPlatformPlugin.kt +++ b/packages/busymax_android_platform/android/src/main/kotlin/io/busystack/busymax_android_platform/BusymaxAndroidPlatformPlugin.kt @@ -32,7 +32,10 @@ import com.microsoft.identity.client.IAuthenticationResult import com.microsoft.identity.client.IMultipleAccountPublicClientApplication import com.microsoft.identity.client.Prompt import com.microsoft.identity.client.PublicClientApplication +import com.microsoft.identity.client.exception.MsalClientException import com.microsoft.identity.client.exception.MsalException +import com.microsoft.identity.client.exception.MsalServiceException +import com.microsoft.identity.client.exception.MsalUiRequiredException import io.flutter.embedding.engine.plugins.FlutterPlugin import io.flutter.embedding.engine.plugins.activity.ActivityAware import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding @@ -87,6 +90,67 @@ internal object EngineOwnedAccountGateRegistry { } } +internal data class MicrosoftSilentFailure( + val code: String, + val message: String, +) + +internal class MicrosoftAccountMissingException : IllegalStateException() + +/** Maps MSAL failures without turning transport or configuration faults into logout state. */ +internal fun classifyMicrosoftSilentFailure(error: Throwable): MicrosoftSilentFailure = when (error) { + is MicrosoftAccountMissingException, + is MsalUiRequiredException -> MicrosoftSilentFailure( + "android/auth-interaction-required", + "Microsoft authorization must be reconnected.", + ) + is MsalServiceException -> { + val configurationCodes = setOf( + MsalServiceException.INVALID_REQUEST, + MsalServiceException.UNAUTHORIZED_CLIENT, + MsalServiceException.INVALID_SCOPE, + MsalServiceException.INVALID_INSTANCE, + ) + if (error.errorCode in configurationCodes) { + MicrosoftSilentFailure( + "android/microsoft-not-configured", + "Microsoft authorization is not configured for this account.", + ) + } else { + MicrosoftSilentFailure( + "android/auth-temporary-failure", + "Microsoft authorization is temporarily unavailable.", + ) + } + } + is MsalClientException -> { + val configurationCodes = setOf( + MsalClientException.INVALID_PARAMETER, + MsalClientException.SCOPE_EMPTY_OR_NULL, + MsalClientException.JSON_PARSE_FAILURE, + MsalClientException.MALFORMED_URL, + MsalClientException.UNKNOWN_AUTHORITY, + MsalClientException.APP_MANIFEST_VALIDATION_ERROR, + MsalClientException.REDIRECT_URI_VALIDATION_ERROR, + ) + if (error.errorCode in configurationCodes) { + MicrosoftSilentFailure( + "android/microsoft-not-configured", + "Microsoft authorization is not configured correctly.", + ) + } else { + MicrosoftSilentFailure( + "android/auth-temporary-failure", + "Microsoft authorization is temporarily unavailable.", + ) + } + } + else -> MicrosoftSilentFailure( + "android/microsoft-authorization-failed", + "Microsoft authorization failed.", + ) +} + /** BusyMax-owned Android integration. Provider refresh tokens never enter Dart. */ class BusymaxAndroidPlatformPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, @@ -316,12 +380,13 @@ class BusymaxAndroidPlatformPlugin : FlutterPlugin, executor.execute { try { val account = application.accounts.firstOrNull { it.id == nativeId } - ?: throw IllegalStateException("Microsoft account is not in the MSAL cache.") + ?: throw MicrosoftAccountMissingException() val parameters = AcquireTokenSilentParameters.Builder() .withScopes(scopes).forAccount(account).fromAuthority(account.authority).build() postSuccess(result, msalResult(application.acquireTokenSilent(parameters))) } catch (error: Exception) { - postError(result, "android/auth-interaction-required", "Microsoft authorization must be reconnected.", error) + val failure = classifyMicrosoftSilentFailure(error) + postError(result, failure.code, failure.message, error) } } } diff --git a/packages/busymax_android_platform/android/src/test/kotlin/io/busystack/busymax_android_platform/BusymaxAndroidPlatformPluginTest.kt b/packages/busymax_android_platform/android/src/test/kotlin/io/busystack/busymax_android_platform/BusymaxAndroidPlatformPluginTest.kt index d901a0c5..1f3005b3 100644 --- a/packages/busymax_android_platform/android/src/test/kotlin/io/busystack/busymax_android_platform/BusymaxAndroidPlatformPluginTest.kt +++ b/packages/busymax_android_platform/android/src/test/kotlin/io/busystack/busymax_android_platform/BusymaxAndroidPlatformPluginTest.kt @@ -2,6 +2,10 @@ package io.busystack.busymax_android_platform import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodChannel +import com.microsoft.identity.client.exception.MsalClientException +import com.microsoft.identity.client.exception.MsalServiceException +import com.microsoft.identity.client.exception.MsalUiRequiredException +import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertNotNull import org.junit.jupiter.api.Test import org.mockito.Mockito @@ -27,4 +31,36 @@ internal class BusymaxAndroidPlatformPluginTest { assertNotNull(second) EngineOwnedAccountGateRegistry.releaseOwned("engine-2") } + + @Test + fun microsoftSilentFailures_preserveAuthenticationForTransientErrors() { + assertEquals( + "android/auth-interaction-required", + classifyMicrosoftSilentFailure(MicrosoftAccountMissingException()).code, + ) + assertEquals( + "android/auth-interaction-required", + classifyMicrosoftSilentFailure( + MsalUiRequiredException(MsalUiRequiredException.NO_TOKENS_FOUND, "No token"), + ).code, + ) + assertEquals( + "android/auth-temporary-failure", + classifyMicrosoftSilentFailure( + MsalServiceException(MsalServiceException.SERVICE_NOT_AVAILABLE, "Unavailable", null), + ).code, + ) + assertEquals( + "android/auth-temporary-failure", + classifyMicrosoftSilentFailure( + MsalClientException(MsalClientException.DEVICE_NETWORK_NOT_AVAILABLE, "Offline"), + ).code, + ) + assertEquals( + "android/microsoft-not-configured", + classifyMicrosoftSilentFailure( + MsalClientException(MsalClientException.REDIRECT_URI_VALIDATION_ERROR, "Bad redirect"), + ).code, + ) + } } diff --git a/test/platform/android/android_adaptive_app_test.dart b/test/platform/android/android_adaptive_app_test.dart index e9fcf89a..0980fad3 100644 --- a/test/platform/android/android_adaptive_app_test.dart +++ b/test/platform/android/android_adaptive_app_test.dart @@ -1,14 +1,26 @@ +import 'dart:convert'; + import 'package:busymax/src/android/android_app.dart'; import 'package:busymax/src/android/android_notifications.dart'; +import 'package:busymax/src/android/presentation/android_availability_dialog.dart'; +import 'package:busymax/src/android/presentation/android_diagnostics_screen.dart'; +import 'package:busymax/src/android/presentation/android_feedback_screen.dart'; import 'package:busymax/src/android/presentation/android_schedule_screen.dart'; +import 'package:busymax/src/android/presentation/android_settings_screen.dart'; import 'package:busymax/src/android/presentation/android_tasks_screen.dart'; import 'package:busymax/src/app/app_bootstrap.dart'; import 'package:busymax/src/config/build_config.dart'; import 'package:busymax/src/db/app_database.dart'; import 'package:busymax/src/features/calendar/data/calendar_repository.dart'; import 'package:busymax/src/features/calendar/presentation/event_editor_draft.dart'; +import 'package:busymax/src/features/feedback/data/feedback_api_client.dart'; +import 'package:busymax/src/features/feedback/data/feedback_submission.dart'; import 'package:busymax/src/features/notifications/notification_reconciler.dart'; +import 'package:busymax/src/features/recurrence/domain/recurrence_rule.dart'; +import 'package:busymax/src/features/sync/pending_mutation_sync_requester.dart'; +import 'package:busymax/src/features/task_lists/data/task_lists_repository.dart'; import 'package:busymax/src/features/tasks/data/tasks_repository.dart'; +import 'package:busymax/src/features/tasks/domain/task_capabilities.dart'; import 'package:busymax/src/providers/busy_provider.dart'; import 'package:busymax/src/schedule/schedule_repository.dart'; import 'package:busymax/src/schedule/schedule_view_mode.dart'; @@ -17,6 +29,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:package_info_plus/package_info_plus.dart'; import '../../support/memory_settings_store.dart'; @@ -30,8 +43,10 @@ void main() { ); final messenger = TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + late Map secureStorage; setUp(() { + secureStorage = {}; messenger.setMockMethodCallHandler(platformChannel, (call) async { return switch (call.method) { 'takeInitialActivation' => null, @@ -41,7 +56,30 @@ void main() { }; }); messenger.setMockMethodCallHandler(eventChannel, (_) async => null); - messenger.setMockMethodCallHandler(secureStorageChannel, (_) async => null); + messenger.setMockMethodCallHandler(secureStorageChannel, (call) async { + final arguments = (call.arguments as Map?)?.cast() ?? {}; + final key = arguments['key']?.toString(); + return switch (call.method) { + 'read' => key == null ? null : secureStorage[key], + 'write' => () { + if (key != null) { + secureStorage[key] = arguments['value']?.toString() ?? ''; + } + return null; + }(), + 'delete' => () { + if (key != null) secureStorage.remove(key); + return null; + }(), + 'deleteAll' => () { + secureStorage.clear(); + return null; + }(), + 'containsKey' => key != null && secureStorage.containsKey(key), + 'readAll' => secureStorage, + _ => null, + }; + }); }); tearDown(() { @@ -77,6 +115,7 @@ void main() { tester.view.devicePixelRatio = 1; addTearDown(tester.view.resetPhysicalSize); addTearDown(tester.view.resetDevicePixelRatio); + addTearDown(tester.platformDispatcher.clearTextScaleFactorTestValue); final settings = AppSettings.defaults().copyWith( localeTag: 'ar', @@ -167,6 +206,7 @@ void main() { tester.view.devicePixelRatio = 1; addTearDown(tester.view.resetPhysicalSize); addTearDown(tester.view.resetDevicePixelRatio); + addTearDown(tester.platformDispatcher.clearTextScaleFactorTestValue); final settings = AppSettings.defaults().copyWith( androidScheduleViewMode: ScheduleViewMode.day, @@ -179,9 +219,63 @@ void main() { expect(find.byKey(const ValueKey('android-day-time-grid')), findsOneWidget); expect(find.textContaining('Overnight event'), findsWidgets); + expect(find.textContaining('more'), findsWidgets); expect(find.text('Hidden event'), findsNothing); expect(find.text('Hidden task'), findsNothing); expect(tester.takeException(), isNull); + await tester.tap(find.textContaining('more').first); + await tester.pumpAndSettle(); + await tester.tap(find.text('Invitation event')); + await tester.pumpAndSettle(); + expect(find.text('Accept'), findsOneWidget); + expect(find.text('Tentative'), findsOneWidget); + expect(find.text('Decline'), findsOneWidget); + tester.state(find.byType(Navigator).first).pop(); + await tester.pumpAndSettle(); + await tester.tap(find.textContaining('more').first); + await tester.pumpAndSettle(); + expect(find.text('All-day event 3'), findsOneWidget); + expect(find.text('Date-only task'), findsOneWidget); + await tester.tap(find.text('All-day event 3')); + await tester.pumpAndSettle(); + expect(find.text('All-day event 3'), findsOneWidget); + tester.state(find.byType(Navigator).first).pop(); + await tester.pumpAndSettle(); + + await harness.container + .read(appSettingsControllerProvider.notifier) + .setAndroidScheduleViewMode(ScheduleViewMode.week); + await tester.pumpAndSettle(); + expect( + find.byKey(const ValueKey('android-week-time-grid')), + findsOneWidget, + ); + expect(find.textContaining('Overnight event'), findsWidgets); + expect(find.textContaining('more'), findsWidgets); + expect(tester.takeException(), isNull); + await tester.tap(find.textContaining('more').first); + await tester.pumpAndSettle(); + expect(find.text('Date-only task'), findsOneWidget); + tester.state(find.byType(Navigator).first).pop(); + await tester.pumpAndSettle(); + + tester.view.physicalSize = const Size(800, 900); + tester.binding.handleMetricsChanged(); + await tester.pumpAndSettle(); + expect( + find.byKey(const ValueKey('android-week-time-grid')), + findsOneWidget, + ); + await tester.tap(find.textContaining('more').first); + await tester.pumpAndSettle(); + expect(find.text('Date-only task'), findsOneWidget); + tester.state(find.byType(Navigator).first).pop(); + await tester.pumpAndSettle(); + + tester.view.physicalSize = const Size(320, 500); + tester.platformDispatcher.textScaleFactorTestValue = 2; + tester.binding.handleMetricsChanged(); + tester.binding.handleTextScaleFactorChanged(); await harness.container .read(appSettingsControllerProvider.notifier) @@ -199,6 +293,53 @@ void main() { expect(tester.takeException(), isNull); }); + testWidgets('Android invitation response queues the provider payload', ( + tester, + ) async { + tester.view.physicalSize = const Size(412, 915); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + var syncRequests = 0; + final harness = await _pumpApp( + tester, + AppSettings.defaults().copyWith( + androidScheduleViewMode: ScheduleViewMode.day, + ), + populated: true, + onCalendarSync: (_) async => syncRequests += 1, + ); + addTearDown(harness.dispose); + + await tester.tap(find.textContaining('more').first); + await tester.pumpAndSettle(); + await tester.tap(find.text('Invitation event')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Accept')); + await tester.pumpAndSettle(); + await tester.pump(const Duration(milliseconds: 1)); + await tester.runAsync( + () => Future.delayed(const Duration(milliseconds: 1)), + ); + + final event = await (harness.database.select( + harness.database.calendarEvents, + )..where((row) => row.id.equals('invitation'))).getSingle(); + final attendees = jsonDecode(event.attendeesJson!) as List; + expect((attendees.single as Map)['responseStatus'], 'accepted'); + final operation = await harness.database + .select(harness.database.pendingOps) + .getSingle(); + expect(operation.operationType, 'event.respond'); + expect(jsonDecode(operation.requestJson), { + 'response': 'accept', + 'attendeeEmail': 'me@example.com', + 'sendResponse': true, + }); + expect(syncRequests, 1); + expect(tester.takeException(), isNull); + }); + testWidgets('notification event with no writable source opens read-only', ( tester, ) async { @@ -235,6 +376,114 @@ void main() { expect(tester.takeException(), isNull); }); + testWidgets( + 'Google event editor exposes guest availability with an error path', + (tester) async { + final harness = await _pumpApp(tester, AppSettings.defaults()); + addTearDown(harness.dispose); + final now = DateTime.now(); + const source = CalendarSourceEntity( + id: 'calendar', + accountId: 'google:editable', + provider: BusyProvider.google, + providerCalendarId: 'calendar', + summary: 'Editable calendar', + selected: true, + hidden: false, + readOnly: false, + isDeleted: false, + ); + tester + .state(find.byType(Navigator).first) + .push( + MaterialPageRoute( + builder: (_) => AndroidEventEditor( + sources: const [source], + draft: EventEditorDraft.existing( + eventId: 'availability-event', + accountId: source.accountId, + sourceId: source.id, + providerCalendarId: source.providerCalendarId, + title: 'Availability event', + allDay: false, + start: now, + end: now.add(const Duration(hours: 1)), + attendees: const [ + EventAttendeeDraft(email: 'guest@example.com'), + ], + ), + ), + ), + ); + await tester.pumpAndSettle(); + final action = find.text('Check guest availability'); + await _scrollUntilBuilt(tester, action); + await tester.ensureVisible(action); + await tester.tap(action); + await tester.pumpAndSettle(); + + expect(find.byType(AndroidGuestAvailabilityDialog), findsOneWidget); + expect( + find.textContaining('Availability is unavailable'), + findsOneWidget, + ); + expect(tester.takeException(), isNull); + }, + ); + + testWidgets('Android Settings exposes diagnostics and feedback routes', ( + tester, + ) async { + final harness = await _pumpApp(tester, AppSettings.defaults()); + addTearDown(harness.dispose); + harness.container.read(androidSelectedDestinationProvider.notifier).state = + 2; + await tester.pumpAndSettle(); + + final apple = find.text('Add Apple iCloud Calendar account'); + await _scrollUntilBuilt(tester, apple); + await tester.tap(apple); + await tester.pumpAndSettle(); + await tester.enterText( + find.widgetWithText(TextField, 'Apple Account email'), + 'qa@example.com', + ); + await tester.enterText( + find.widgetWithText(TextField, 'App-specific password'), + 'test-password', + ); + await tester.tap(find.widgetWithText(TextButton, 'Cancel')); + await tester.pumpAndSettle(); + + final diagnostics = find.text('Diagnostics'); + await _scrollUntilBuilt(tester, diagnostics); + await Scrollable.ensureVisible( + tester.element(diagnostics), + alignment: .5, + duration: Duration.zero, + ); + await tester.pump(); + await tester.tap(diagnostics); + await tester.pumpAndSettle(); + expect(find.byType(AndroidDiagnosticsScreen), findsOneWidget); + tester.state(find.byType(Navigator).first).pop(); + await tester.pumpAndSettle(); + + final feedback = find.text('Send feedback'); + await _scrollUntilBuilt(tester, feedback); + await Scrollable.ensureVisible( + tester.element(feedback), + alignment: .5, + duration: Duration.zero, + ); + await tester.pump(); + await tester.tap(feedback); + await tester.pumpAndSettle(); + expect(find.byType(AndroidFeedbackScreen), findsOneWidget); + expect(find.text('Detailed message'), findsOneWidget); + expect(tester.takeException(), isNull); + }); + testWidgets('read-only task disables save and omits delete', (tester) async { final harness = await _pumpApp(tester, AppSettings.defaults()); addTearDown(harness.dispose); @@ -373,14 +622,690 @@ void main() { expect(find.byType(AndroidTaskEditor), findsNothing); expect(tester.takeException(), isNull); }); + + for (final (label, fieldIndex) in const [ + ('Location', 1), + ('Description', 2), + ('Guests', 3), + ('Categories', 4), + ]) { + testWidgets('system Back protects event $label edits', (tester) async { + final harness = await _pumpApp(tester, AppSettings.defaults()); + addTearDown(harness.dispose); + final now = DateTime.now(); + const source = CalendarSourceEntity( + id: 'calendar', + accountId: 'google:editable', + provider: BusyProvider.google, + providerCalendarId: 'calendar', + summary: 'Editable calendar', + selected: true, + hidden: false, + readOnly: false, + isDeleted: false, + ); + tester + .state(find.byType(Navigator).first) + .push( + MaterialPageRoute( + builder: (_) => AndroidEventEditor( + sources: const [source], + draft: EventEditorDraft.existing( + eventId: 'event-$fieldIndex', + accountId: source.accountId, + sourceId: source.id, + providerCalendarId: source.providerCalendarId, + title: 'System Back event', + allDay: false, + start: now, + end: now.add(const Duration(hours: 1)), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + final labelText = find.descendant( + of: find.byType(AndroidEventEditor), + matching: find.text(label), + ); + await _scrollUntilBuilt(tester, labelText); + final field = find.ancestor( + of: labelText, + matching: find.byType(TextFormField), + ); + await tester.ensureVisible(field); + await tester.enterText(field, 'Edited $label'); + await tester.pump(); + await tester.binding.handlePopRoute(); + await tester.pumpAndSettle(); + + expect(find.text('Discard changes?'), findsOneWidget); + await tester.tap(find.widgetWithText(TextButton, 'Cancel')); + await tester.pumpAndSettle(); + expect(find.byType(AndroidEventEditor), findsOneWidget); + expect(find.text('Edited $label'), findsOneWidget); + expect(tester.takeException(), isNull); + }); + } + + for (final label in const [ + 'Title', + 'Notes', + 'Categories', + 'Location', + 'URL', + ]) { + testWidgets('system Back protects task $label edits', (tester) async { + final harness = await _pumpApp(tester, AppSettings.defaults()); + addTearDown(harness.dispose); + const task = TaskEntity( + accountId: 'nextcloud:editable', + taskListId: 'editable-list', + id: 'editable-task', + title: 'System Back task', + localDirty: false, + pendingDelete: false, + pendingMove: false, + rawJson: '{}', + updatedLocalAtUtc: '2026-09-14T00:00:00.000Z', + ); + tester + .state(find.byType(Navigator).first) + .push( + MaterialPageRoute( + builder: (_) => const AndroidTaskEditor( + accountId: 'nextcloud:editable', + provider: BusyProvider.nextcloud, + task: task, + accountLabel: 'Editable account', + listLabel: 'Editable list', + ), + ), + ); + await tester.pumpAndSettle(); + + final labelText = find.descendant( + of: find.byType(AndroidTaskEditor), + matching: find.text(label), + ); + await _scrollUntilBuilt(tester, labelText); + final field = find.ancestor( + of: labelText, + matching: find.byType(TextField), + ); + await tester.ensureVisible(field); + await tester.enterText(field, 'Edited $label'); + await tester.pump(); + await tester.binding.handlePopRoute(); + await tester.pumpAndSettle(); + + expect(find.text('Discard changes?'), findsOneWidget); + await tester.tap(find.widgetWithText(TextButton, 'Cancel')); + await tester.pumpAndSettle(); + expect(find.byType(AndroidTaskEditor), findsOneWidget); + expect(find.text('Edited $label'), findsOneWidget); + expect(tester.takeException(), isNull); + }); + } + + testWidgets('event recovery restores its changed account and calendar', ( + tester, + ) async { + final harness = await _pumpApp(tester, AppSettings.defaults()); + addTearDown(harness.dispose); + final now = DateTime.now(); + const sourceA = CalendarSourceEntity( + id: 'calendar-a', + accountId: 'google:a', + provider: BusyProvider.google, + providerCalendarId: 'provider-a', + summary: 'Calendar A', + selected: true, + hidden: false, + readOnly: false, + isDeleted: false, + ); + const sourceB = CalendarSourceEntity( + id: 'calendar-b', + accountId: 'google:b', + provider: BusyProvider.google, + providerCalendarId: 'provider-b', + summary: 'Calendar B', + selected: true, + hidden: false, + readOnly: false, + isDeleted: false, + ); + final initial = EventEditorDraft.newEvent( + accountId: sourceA.accountId, + sourceId: sourceA.id, + providerCalendarId: sourceA.providerCalendarId, + start: now, + end: now.add(const Duration(hours: 1)), + ).copyWith(showAs: 'opaque', visibilityOrSensitivity: 'private'); + final navigator = tester.state( + find.byType(Navigator).first, + ); + void open() => navigator.push( + MaterialPageRoute( + builder: (_) => AndroidEventEditor( + sources: const [sourceA, sourceB], + draft: initial, + ), + ), + ); + open(); + await tester.pumpAndSettle(); + + await tester.tap(find.byType(DropdownButtonFormField).first); + await tester.pumpAndSettle(); + await tester.tap(find.text('Calendar B · google:b').last); + await tester.enterText(find.byType(TextFormField).first, 'Recovered event'); + await tester.pump(const Duration(milliseconds: 1100)); + await tester.pumpAndSettle(); + const key = 'busymax.android.event-draft.google:a.new-calendar-a'; + final saved = (jsonDecode(secureStorage[key]!) as Map) + .cast(); + expect(saved['accountId'], sourceB.accountId); + expect(saved['sourceId'], sourceB.id); + expect(saved['providerCalendarId'], sourceB.providerCalendarId); + + final route = ModalRoute.of( + tester.element(find.byType(AndroidEventEditor)), + )!; + navigator.removeRoute(route); + await tester.pumpAndSettle(); + open(); + await tester.pumpAndSettle(); + await tester.tap(find.text('Restore')); + await tester.pumpAndSettle(); + + expect(find.text('Recovered event'), findsOneWidget); + expect(find.text('Calendar B · google:b'), findsOneWidget); + await tester.pump(const Duration(milliseconds: 1100)); + final rewritten = (jsonDecode(secureStorage[key]!) as Map) + .cast(); + expect(rewritten['showAs'], isNull); + expect(rewritten['visibility'], isNull); + expect(tester.takeException(), isNull); + }); + + testWidgets('event recovery preserves a recurring mutation scope', ( + tester, + ) async { + final harness = await _pumpApp(tester, AppSettings.defaults()); + addTearDown(harness.dispose); + final now = DateTime.now(); + const source = CalendarSourceEntity( + id: 'calendar', + accountId: 'google:a', + provider: BusyProvider.google, + providerCalendarId: 'provider-a', + summary: 'Calendar', + selected: true, + hidden: false, + readOnly: false, + isDeleted: false, + ); + tester + .state(find.byType(Navigator).first) + .push( + MaterialPageRoute( + builder: (_) => AndroidEventEditor( + sources: const [source], + draft: EventEditorDraft.existing( + eventId: 'occurrence', + providerRecurringEventId: 'series', + accountId: source.accountId, + sourceId: source.id, + providerCalendarId: source.providerCalendarId, + title: 'Recurring event', + allDay: false, + start: now, + end: now.add(const Duration(hours: 1)), + ), + ), + ), + ); + await tester.pumpAndSettle(); + final scopeAction = find.text( + 'Choose whether this change applies to the entire series, only this occurrence, or this and following events.', + ); + await _scrollUntilBuilt(tester, scopeAction); + await Scrollable.ensureVisible( + tester.element(scopeAction), + alignment: .5, + duration: Duration.zero, + ); + await tester.pump(); + await tester.tap(scopeAction); + await tester.pumpAndSettle(); + await tester.tap(find.text('This event')); + await tester.pump(const Duration(milliseconds: 1100)); + await tester.pumpAndSettle(); + + const key = 'busymax.android.event-draft.google:a.occurrence'; + final saved = (jsonDecode(secureStorage[key]!) as Map) + .cast(); + expect(saved['recurringMutationScope'], 'singleOccurrence'); + expect(tester.takeException(), isNull); + }); + + testWidgets('task recovery retains full creation recurrence semantics', ( + tester, + ) async { + final harness = await _pumpApp(tester, AppSettings.defaults()); + addTearDown(harness.dispose); + const list = TaskListEntity( + accountId: 'nextcloud:editable', + id: 'list-a', + title: 'Tasks', + localDirty: false, + pendingDelete: false, + rawJson: '{}', + ); + final rule = RecurrenceRule.fromIcalendar( + rules: const ['FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,WE;COUNT=8'], + baseDate: DateTime(2026, 9, 14), + ); + const key = 'busymax.android.task-draft.nextcloud:editable.new-list-a'; + secureStorage[key] = jsonEncode({ + 'v': 2, + 'savedAt': DateTime.now().toIso8601String(), + 'baseTitle': '', + 'accountId': 'nextcloud:editable', + 'taskListId': 'list-a', + 'title': 'Recovered repeating task', + 'notes': '', + 'dueDate': '2026-09-14', + 'dueTime': '09:00', + 'dueTimeZone': 'America/Vancouver', + 'startDate': '2026-09-14', + 'startTime': '08:00', + 'startTimeZone': 'America/Vancouver', + 'reminderEnabled': true, + 'reminderDate': '2026-09-14', + 'reminderTime': '07:30', + 'reminderTimeZone': 'America/Vancouver', + 'creationRecurrence': { + 'provider': 'nextcloud', + 'rule': rule.toJsonString(), + }, + 'importance': 'normal', + 'categories': [], + 'icalPriority': 0, + 'percentComplete': 0, + 'classification': 'PUBLIC', + 'pinned': false, + 'hideSubtasks': false, + 'hideCompletedSubtasks': false, + 'alarms': [], + }); + tester + .state(find.byType(Navigator).first) + .push( + MaterialPageRoute( + builder: (_) => const AndroidTaskEditor( + accountId: 'nextcloud:editable', + provider: BusyProvider.nextcloud, + creationList: list, + accountLabel: 'Editable account', + listLabel: 'Tasks', + ), + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('Restore')); + await tester.pumpAndSettle(); + expect(find.text('Recovered repeating task'), findsOneWidget); + + await tester.enterText(find.widgetWithText(TextField, 'Notes'), 'Keep it'); + await tester.pump(const Duration(milliseconds: 1100)); + await tester.pumpAndSettle(); + final saved = (jsonDecode(secureStorage[key]!) as Map) + .cast(); + final creation = (saved['creationRecurrence']! as Map) + .cast(); + final recoveredRule = RecurrenceRule.fromJson( + creation['rule']?.toString(), + baseDate: DateTime(2026, 9, 14), + ); + expect(recoveredRule.frequency, rule.frequency); + expect(recoveredRule.interval, 2); + expect(recoveredRule.byDay, ['MO', 'WE']); + expect(recoveredRule.count, 8); + expect(recoveredRule.untilRaw, isNull); + expect(saved['dueTimeZone'], 'America/Vancouver'); + expect(saved['startTimeZone'], 'America/Vancouver'); + expect(saved['reminderTimeZone'], 'America/Vancouver'); + expect(tester.takeException(), isNull); + }); + + testWidgets('dirty task save-and-move persists edits before moving', ( + tester, + ) async { + final harness = await _pumpApp(tester, AppSettings.defaults()); + addTearDown(harness.dispose); + await _seedMovableTask(harness.database); + expect( + await harness.database.taskListsDao.listTaskLists('google:move'), + hasLength(3), + ); + await tester.pump(); + await _openMovableTaskEditor(tester, _movableTask()); + + await tester.enterText( + find.widgetWithText(TextField, 'Title'), + 'Edited before move', + ); + await tester.pump(); + final move = find.widgetWithText(OutlinedButton, 'List'); + await _scrollUntilBuilt(tester, move); + expect(tester.widget(move).onPressed, isNot(equals(null))); + tester.widget(move).onPressed!(); + await _pumpUntilFound(tester, find.text('Destination list')); + expect(tester.takeException(), isNull); + expect(find.byType(BottomSheet), findsOneWidget); + await tester.tap(find.text('Destination list')); + await tester.pumpAndSettle(); + await tester.tap(find.widgetWithText(FilledButton, 'Save')); + await tester.pumpAndSettle(); + + expect(find.byType(AndroidTaskEditor), findsNothing); + final row = await (harness.database.select( + harness.database.tasks, + )..where((task) => task.id.equals('move-task'))).getSingle(); + expect(row.title, 'Edited before move'); + expect(row.taskListId, 'destination-list'); + final operations = await harness.database + .select(harness.database.pendingOps) + .get(); + expect( + operations.map((operation) => operation.operation), + containsAllInOrder(['patch_task', 'move_task']), + ); + expect( + secureStorage, + isNot(contains('busymax.android.task-draft.google:move.move-task')), + ); + expect(tester.takeException(), isNull); + }); + + testWidgets('cancelling a dirty task move preserves the draft', ( + tester, + ) async { + final harness = await _pumpApp(tester, AppSettings.defaults()); + addTearDown(harness.dispose); + await _seedMovableTask(harness.database); + await tester.pump(); + await _openMovableTaskEditor(tester, _movableTask()); + + await tester.enterText( + find.widgetWithText(TextField, 'Title'), + 'Unsaved move edit', + ); + await tester.pump(); + final move = find.widgetWithText(OutlinedButton, 'List'); + await _scrollUntilBuilt(tester, move); + tester.widget(move).onPressed!(); + await _pumpUntilFound(tester, find.text('Destination list')); + await tester.tap(find.text('Destination list')); + await tester.pumpAndSettle(); + await tester.tap(find.widgetWithText(TextButton, 'Cancel')); + await tester.pumpAndSettle(); + + expect(find.byType(AndroidTaskEditor), findsOneWidget); + expect(find.text('Unsaved move edit'), findsOneWidget); + final row = await (harness.database.select( + harness.database.tasks, + )..where((task) => task.id.equals('move-task'))).getSingle(); + expect(row.title, 'Original move task'); + expect(row.taskListId, 'source-list'); + expect( + await harness.database.select(harness.database.pendingOps).get(), + isEmpty, + ); + expect(tester.takeException(), isNull); + }); + + testWidgets('failed task move retains editor state and recovery', ( + tester, + ) async { + final harness = await _pumpApp(tester, AppSettings.defaults()); + addTearDown(harness.dispose); + await _seedMovableTask(harness.database, includeSource: false); + await tester.pump(); + await _openMovableTaskEditor( + tester, + _movableTask(taskListId: 'missing-source'), + ); + + await tester.enterText( + find.widgetWithText(TextField, 'Title'), + 'Recover after move failure', + ); + await tester.pump(const Duration(milliseconds: 1100)); + final recoveryKey = 'busymax.android.task-draft.google:move.move-task'; + expect(secureStorage, contains(recoveryKey)); + final move = find.widgetWithText(OutlinedButton, 'List'); + await _scrollUntilBuilt(tester, move); + tester.widget(move).onPressed!(); + await _pumpUntilFound(tester, find.text('Destination list')); + await tester.tap(find.text('Destination list')); + await tester.pumpAndSettle(); + await tester.tap(find.widgetWithText(TextButton, 'Discard')); + await tester.pumpAndSettle(); + + expect(find.byType(AndroidTaskEditor), findsOneWidget); + expect(find.text('Recover after move failure'), findsOneWidget); + expect(secureStorage, contains(recoveryKey)); + expect(find.byType(SnackBar), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + testWidgets('calendar administration requests prompt pending sync', ( + tester, + ) async { + var syncRequests = 0; + final harness = await _pumpApp( + tester, + AppSettings.defaults(), + seedDatabase: _seedCalendarAdministration, + onCalendarSync: (_) async => syncRequests += 1, + ); + addTearDown(harness.dispose); + tester + .state(find.byType(Navigator).first) + .push( + MaterialPageRoute(builder: (_) => const AndroidSettingsScreen()), + ); + await tester.pumpAndSettle(); + + final source = find.text('Administration calendar'); + await _scrollUntilBuilt(tester, source); + await tester.tap(source); + await tester.pumpAndSettle(); + await tester.tap(find.text('Rename')); + await tester.pumpAndSettle(); + await tester.enterText(find.byType(TextField).last, 'Renamed calendar'); + await tester.tap(find.widgetWithText(FilledButton, 'Save')); + await tester.pumpAndSettle(); + await tester.pump(const Duration(milliseconds: 1)); + expect(syncRequests, 1); + await tester.runAsync( + () => Future.delayed(const Duration(milliseconds: 1)), + ); + + final renamed = find.text('Renamed calendar'); + await _scrollUntilBuilt(tester, renamed); + await tester.tap(renamed); + await tester.pumpAndSettle(); + await tester.tap(find.text('Calendar color')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Color 1')); + await tester.pumpAndSettle(); + await tester.pump(const Duration(milliseconds: 1)); + expect(syncRequests, 2); + await tester.runAsync( + () => Future.delayed(const Duration(milliseconds: 1)), + ); + await harness.database.delete(harness.database.pendingOps).go(); + + await _scrollUntilBuilt(tester, renamed); + await tester.tap(renamed); + await tester.pumpAndSettle(); + await tester.tap(find.text('Delete')); + await tester.pumpAndSettle(); + await tester.tap(find.widgetWithText(FilledButton, 'Delete')); + await tester.pumpAndSettle(); + await tester.pump(const Duration(milliseconds: 1)); + await tester.runAsync( + () => Future.delayed(const Duration(milliseconds: 1)), + ); + expect(syncRequests, 3); + expect(find.text('Renamed calendar'), findsNothing); + final row = await harness.database + .select(harness.database.calendarSources) + .getSingle(); + expect(row.isDeleted, isTrue); + expect(tester.takeException(), isNull); + }); + + testWidgets('Android feedback preserves failure and rotates edited retry', ( + tester, + ) async { + PackageInfo.setMockInitialValues( + appName: 'BusyMax', + packageName: 'io.busystack.busymax', + version: '0.2.2', + buildNumber: '2', + buildSignature: '', + ); + final feedback = _RecordingFeedbackService(); + final harness = await _pumpApp( + tester, + AppSettings.defaults(), + feedbackService: feedback, + ); + addTearDown(harness.dispose); + tester + .state(find.byType(Navigator).first) + .push( + MaterialPageRoute(builder: (_) => const AndroidFeedbackScreen()), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.byType(DropdownButtonFormField)); + await tester.pumpAndSettle(); + await tester.tap(find.text('Problem or bug').last); + await tester.enterText( + find.byKey(const ValueKey('android-feedback-subject')), + 'First subject', + ); + await tester.enterText( + find.byKey(const ValueKey('android-feedback-message')), + 'A detailed failure message', + ); + await tester.tap(find.text('Submit')); + await tester.pumpAndSettle(); + + expect(feedback.submissions, hasLength(1)); + final firstId = feedback.submissions.single.submissionId; + expect( + find.textContaining('could not accept your feedback'), + findsOneWidget, + ); + expect(find.text('A detailed failure message'), findsOneWidget); + + feedback.fail = false; + await tester.enterText( + find.byKey(const ValueKey('android-feedback-subject')), + 'Edited subject', + ); + await tester.tap(find.text('Submit')); + await tester.pumpAndSettle(); + + expect(feedback.submissions, hasLength(2)); + expect(feedback.submissions.last.submissionId, isNot(firstId)); + expect(find.textContaining('feedback-test-receipt'), findsOneWidget); + expect(find.text('A detailed failure message'), findsNothing); + expect(tester.takeException(), isNull); + }); +} + +TaskEntity _movableTask({String taskListId = 'source-list'}) => TaskEntity( + accountId: 'google:move', + taskListId: taskListId, + id: 'move-task', + title: 'Original move task', + localDirty: false, + pendingDelete: false, + pendingMove: false, + rawJson: '{}', + updatedLocalAtUtc: '2026-09-14T00:00:00.000Z', +); + +Future _openMovableTaskEditor( + WidgetTester tester, + TaskEntity task, +) async { + tester + .state(find.byType(Navigator).first) + .push( + MaterialPageRoute( + builder: (_) => AndroidTaskEditor( + accountId: 'google:move', + provider: BusyProvider.google, + task: task, + accountLabel: 'Move account', + listLabel: task.taskListId == 'source-list' + ? 'Source list' + : 'Missing source', + ), + ), + ); + await tester.pumpAndSettle(); +} + +Future _scrollUntilBuilt(WidgetTester tester, Finder target) async { + for (var attempt = 0; attempt < 12 && target.evaluate().isEmpty; attempt++) { + await tester.drag(find.byType(ListView).last, const Offset(0, -260)); + await tester.pump(); + } + expect(target, findsOneWidget); + await tester.ensureVisible(target); + await tester.pump(); +} + +Future _pumpUntilFound(WidgetTester tester, Finder target) async { + for (var attempt = 0; attempt < 20 && target.evaluate().isEmpty; attempt++) { + await tester.pump(const Duration(milliseconds: 10)); + await tester.runAsync(() => Future.delayed(Duration.zero)); + } + await tester.pumpAndSettle(); + if (target.evaluate().isEmpty) { + final texts = find + .byType(Text) + .evaluate() + .map((element) => (element.widget as Text).data) + .whereType() + .join(' | '); + fail('Target did not appear. Visible text: $texts'); + } + expect(target, findsOneWidget); } Future<_AndroidAppHarness> _pumpApp( WidgetTester tester, AppSettings settings, { bool populated = false, + Future Function(AppDatabase database)? seedDatabase, + Future Function(String accountId)? onCalendarSync, + FeedbackSubmissionService? feedbackService, }) async { final database = AppDatabase.memoryForTests(); + if (seedDatabase != null) await seedDatabase(database); if (populated) await _seedPopulatedSchedule(database); final notifications = AndroidNotificationService( database: database, @@ -393,6 +1318,28 @@ Future<_AndroidAppHarness> _pumpApp( scheduleRepositoryProvider.overrideWithValue( ScheduleRepository(database), ), + taskListsRepositoryForAccountProvider.overrideWith( + (ref, accountId) => + TaskListsRepository(database: database, accountId: accountId), + ), + tasksRepositoryForAccountProvider.overrideWith( + (ref, accountId) => + TasksRepository(database: database, accountId: accountId), + ), + if (onCalendarSync != null) + pendingCalendarMutationSyncRequesterForAccountProvider.overrideWith(( + ref, + accountId, + ) { + final requester = PendingMutationSyncRequester( + sync: () => onCalendarSync(accountId), + debounce: Duration.zero, + ); + ref.onDispose(requester.dispose); + return requester; + }), + if (feedbackService != null) + feedbackSubmissionServiceProvider.overrideWithValue(feedbackService), initialAppSettingsProvider.overrideWithValue(settings), localSettingsStoreProvider.overrideWithValue(MemorySettingsStore()), allAccountsSyncRunnerProvider.overrideWithValue(() async {}), @@ -400,6 +1347,9 @@ Future<_AndroidAppHarness> _pumpApp( CallbackNotificationReconciler(() async {}), ), androidNotificationServiceProvider.overrideWithValue(notifications), + davTaskCollectionCapabilitiesProvider.overrideWith( + (ref, key) async => nextcloudTaskCollectionCapabilities, + ), ], ); await tester.pumpWidget( @@ -446,6 +1396,33 @@ Future _seedPopulatedSchedule(AppDatabase database) async { updatedAtLocal: 1, ), ); + await database.taskListsDao.upsertTaskList( + TaskListsCompanion.insert( + accountId: 'google:populated', + id: 'visible-list', + title: 'Visible tasks', + rawJson: '{}', + createdLocalAtUtc: timestamp, + updatedLocalAtUtc: timestamp, + ), + ); + await database.tasksDao.upsertTask( + TasksCompanion.insert( + accountId: 'google:populated', + taskListId: 'visible-list', + id: 'date-only-task', + title: 'Date-only task', + dueUtc: Value( + '${today.year.toString().padLeft(4, '0')}-' + '${today.month.toString().padLeft(2, '0')}-' + '${today.day.toString().padLeft(2, '0')}', + ), + status: const Value('needsAction'), + rawJson: '{}', + createdLocalAtUtc: timestamp, + updatedLocalAtUtc: timestamp, + ), + ); await database .into(database.calendarEvents) .insert( @@ -464,6 +1441,74 @@ Future _seedPopulatedSchedule(AppDatabase database) async { updatedAtLocal: 1, ), ); + await database + .into(database.calendarEvents) + .insert( + CalendarEventsCompanion.insert( + id: 'invitation', + accountId: 'google:populated', + calendarSourceId: 'calendar', + provider: 'google', + providerCalendarId: 'calendar', + providerEventId: 'invitation', + title: 'Invitation event', + allDay: const Value(true), + startDate: Value( + DateTime( + today.year, + today.month, + today.day, + ).toIso8601String().substring(0, 10), + ), + endDate: Value( + DateTime( + today.year, + today.month, + today.day + 1, + ).toIso8601String().substring(0, 10), + ), + attendeesJson: const Value( + '[{"email":"me@example.com","self":true,"responseStatus":"needsAction"}]', + ), + organizerJson: const Value( + '{"email":"organizer@example.com","self":false}', + ), + rawJson: const Value('{}'), + createdAtLocal: 1, + updatedAtLocal: 1, + ), + ); + for (var index = 1; index <= 4; index++) { + await database + .into(database.calendarEvents) + .insert( + CalendarEventsCompanion.insert( + id: 'all-day-$index', + accountId: 'google:populated', + calendarSourceId: 'calendar', + provider: 'google', + providerCalendarId: 'calendar', + providerEventId: 'all-day-$index', + title: 'All-day event $index', + allDay: const Value(true), + startDate: Value( + '${today.year.toString().padLeft(4, '0')}-' + '${today.month.toString().padLeft(2, '0')}-' + '${today.day.toString().padLeft(2, '0')}', + ), + endDate: Value( + DateTime( + today.year, + today.month, + today.day + 1, + ).toIso8601String().substring(0, 10), + ), + rawJson: const Value('{}'), + createdAtLocal: 1, + updatedAtLocal: 1, + ), + ); + } await database .into(database.calendarSources) .insert( @@ -528,6 +1573,118 @@ Future _seedPopulatedSchedule(AppDatabase database) async { ); } +Future _seedMovableTask( + AppDatabase database, { + bool includeSource = true, +}) async { + const timestamp = '2026-09-14T00:00:00.000Z'; + await database + .into(database.accounts) + .insert( + AccountsCompanion.insert( + id: 'google:move', + provider: 'google', + authority: 'https://accounts.google.com', + providerAccountId: 'move', + credentialKind: 'oauth', + authState: const Value('signed_in'), + displayName: const Value('Move account'), + createdAtUtc: timestamp, + updatedAtUtc: timestamp, + ), + ); + if (includeSource) { + await database.taskListsDao.upsertTaskList( + TaskListsCompanion.insert( + accountId: 'google:move', + id: 'source-list', + title: 'Source list', + rawJson: '{}', + createdLocalAtUtc: timestamp, + updatedLocalAtUtc: timestamp, + ), + ); + await database.tasksDao.upsertTask( + TasksCompanion.insert( + accountId: 'google:move', + taskListId: 'source-list', + id: 'move-task', + title: 'Original move task', + rawJson: '{}', + createdLocalAtUtc: timestamp, + updatedLocalAtUtc: timestamp, + ), + ); + } + await database.taskListsDao.upsertTaskList( + TaskListsCompanion.insert( + accountId: 'google:move', + id: 'destination-list', + title: 'Destination list', + rawJson: '{}', + createdLocalAtUtc: timestamp, + updatedLocalAtUtc: timestamp, + ), + ); + await database.taskListsDao.upsertTaskList( + TaskListsCompanion.insert( + accountId: 'google:move', + id: 'other-destination-list', + title: 'Other destination list', + rawJson: '{}', + createdLocalAtUtc: timestamp, + updatedLocalAtUtc: timestamp, + ), + ); +} + +Future _seedCalendarAdministration(AppDatabase database) async { + const timestamp = '2026-09-14T00:00:00.000Z'; + await database + .into(database.accounts) + .insert( + AccountsCompanion.insert( + id: 'google:administration', + provider: 'google', + authority: 'https://accounts.google.com', + providerAccountId: 'administration', + credentialKind: 'oauth', + authState: const Value('signed_in'), + displayName: const Value('Administration account'), + email: const Value('owner@example.com'), + createdAtUtc: timestamp, + updatedAtUtc: timestamp, + ), + ); + await database + .into(database.calendarSources) + .insert( + CalendarSourcesCompanion.insert( + id: 'administration-calendar', + accountId: 'google:administration', + provider: 'google', + providerCalendarId: 'administration-calendar', + summary: 'Administration calendar', + accessRole: const Value('owner'), + dataOwner: const Value('owner@example.com'), + createdAtLocal: 1, + updatedAtLocal: 1, + ), + ); +} + +final class _RecordingFeedbackService implements FeedbackSubmissionService { + final List submissions = []; + bool fail = true; + + @override + Future submit(FeedbackSubmission submission) async { + submissions.add(submission); + if (fail) throw const FeedbackServerFailure(statusCode: 503); + return const FeedbackReceipt(id: 'feedback-test-receipt'); + } +} + final class _AndroidAppHarness { const _AndroidAppHarness(this.tester, this.container, this.database); diff --git a/test/platform/android/android_local_network_host_test.dart b/test/platform/android/android_local_network_host_test.dart index e5a29006..d9bac69d 100644 --- a/test/platform/android/android_local_network_host_test.dart +++ b/test/platform/android/android_local_network_host_test.dart @@ -1,3 +1,5 @@ +import 'dart:io'; + import 'package:busymax/src/android/presentation/android_settings_screen.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -29,4 +31,50 @@ void main() { expect(isAndroidLocalNetworkHost('8.8.8.8'), isFalse); expect(isAndroidLocalNetworkHost('2001:4860:4860::8888'), isFalse); }); + + test( + 'resolves an ordinary dotted DNS name to its actual LAN address', + () async { + expect( + await requiresAndroidLocalNetworkAccess( + 'cloud.example.com', + lookup: (_) async => [InternetAddress('192.168.1.20')], + ), + isTrue, + ); + }, + ); + + test('does not prompt when an ordinary DNS name resolves publicly', () async { + expect( + await requiresAndroidLocalNetworkAccess( + 'cloud.example.com', + lookup: (_) async => [InternetAddress('203.0.113.10')], + ), + isFalse, + ); + }); + + test( + 'mixed DNS answers require LAN access and lookup failure does not', + () async { + expect( + await requiresAndroidLocalNetworkAccess( + 'cloud.example.com', + lookup: (_) async => [ + InternetAddress('203.0.113.10'), + InternetAddress('fd12:3456::1'), + ], + ), + isTrue, + ); + expect( + await requiresAndroidLocalNetworkAccess( + 'missing.example.com', + lookup: (_) => throw const SocketException('unresolved'), + ), + isFalse, + ); + }, + ); } diff --git a/test/platform/android/android_month_grid_range_test.dart b/test/platform/android/android_month_grid_range_test.dart new file mode 100644 index 00000000..be9705ff --- /dev/null +++ b/test/platform/android/android_month_grid_range_test.dart @@ -0,0 +1,81 @@ +import 'package:busymax/src/android/presentation/android_schedule_screen.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('Sunday-first month query is the exact displayed 42-day grid', () { + final range = androidMonthGridRange( + DateTime(2026, 8, 19), + firstWeekday: DateTime.sunday, + ); + + expect(range.start, DateTime(2026, 7, 26)); + expect(range.end, DateTime(2026, 9, 6)); + expect(_civilDays(range.start, range.end), 42); + }); + + test('Monday-first month query is the exact displayed 42-day grid', () { + final range = androidMonthGridRange( + DateTime(2026, 8, 19), + firstWeekday: DateTime.monday, + ); + + expect(range.start, DateTime(2026, 7, 27)); + expect(range.end, DateTime(2026, 9, 7)); + expect(_civilDays(range.start, range.end), 42); + }); + + test('month grid advances by civil dates rather than 24-hour instants', () { + final range = androidMonthGridRange( + DateTime(2026, 11), + firstWeekday: DateTime.sunday, + ); + final days = [ + for (var index = 0; index < 42; index++) + DateTime(range.start.year, range.start.month, range.start.day + index), + ]; + + expect(days.first, range.start); + expect( + DateTime(days.last.year, days.last.month, days.last.day + 1), + range.end, + ); + expect( + days.map((day) => '${day.year}-${day.month}-${day.day}').toSet(), + hasLength(42), + ); + }); + + test('year query covers every miniature adjacent-date cell', () { + final range = androidYearGridRange( + DateTime(2026), + firstWeekday: DateTime.sunday, + ); + + expect( + range.start, + androidMonthGridRange( + DateTime(2026, DateTime.january), + firstWeekday: DateTime.sunday, + ).start, + ); + expect( + range.end, + androidMonthGridRange( + DateTime(2026, DateTime.december), + firstWeekday: DateTime.sunday, + ).end, + ); + expect(range.start.year, 2025); + expect(range.end.year, 2027); + }); +} + +int _civilDays(DateTime start, DateTime end) { + var current = start; + var count = 0; + while (current.isBefore(end)) { + current = DateTime(current.year, current.month, current.day + 1); + count++; + } + return count; +} diff --git a/test/platform/android/android_notification_policy_test.dart b/test/platform/android/android_notification_policy_test.dart index 0ab0d0e3..080b8e58 100644 --- a/test/platform/android/android_notification_policy_test.dart +++ b/test/platform/android/android_notification_policy_test.dart @@ -61,6 +61,7 @@ void main() { now: 200, horizon: 1000, remainsPending: true, + remainsDisplayed: false, ), isTrue, ); @@ -70,11 +71,25 @@ void main() { now: 200, horizon: 1000, remainsPending: false, + remainsDisplayed: false, ), isFalse, ); }); + test('a delivered reminder remains eligible while it is still visible', () { + expect( + shouldKeepAndroidReminder( + effectiveAt: 100, + now: 200, + horizon: 1000, + remainsPending: false, + remainsDisplayed: true, + ), + isTrue, + ); + }); + test('registration fingerprint changes for privacy and alarm precision', () { final normal = AppSettings.defaults(); final private = normal.copyWith( diff --git a/test/platform/android/android_notification_reconciliation_test.dart b/test/platform/android/android_notification_reconciliation_test.dart new file mode 100644 index 00000000..7e1bcd4d --- /dev/null +++ b/test/platform/android/android_notification_reconciliation_test.dart @@ -0,0 +1,465 @@ +import 'package:busymax/src/android/android_notifications.dart'; +import 'package:busymax/src/app/app_settings.dart'; +import 'package:busymax/src/core/time/stored_temporal_projection.dart'; +import 'package:busymax/src/db/app_database.dart'; +import 'package:busymax/src/providers/busy_provider.dart'; +import 'package:drift/drift.dart' hide isNotNull, isNull; +import 'package:drift/native.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_local_notifications/flutter_local_notifications.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:timezone/timezone.dart' as tz; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + const platformChannel = MethodChannel('io.busystack.busymax/android'); + late AppDatabase database; + late _StatefulNotificationPlugin plugin; + late AppSettings settings; + + setUp(() async { + database = AppDatabase(NativeDatabase.memory()); + plugin = _StatefulNotificationPlugin(); + settings = AppSettings.defaults().copyWith(notifyDueToday: false); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(platformChannel, (call) async { + if (call.method == 'acquireAccountGate') return 'test-lease'; + return null; + }); + await database + .into(database.accounts) + .insert( + AccountsCompanion.insert( + id: 'google:test', + provider: BusyProvider.google.storageValue, + authority: 'google', + providerAccountId: 'test', + credentialKind: 'oauth', + authState: const Value('signed_in'), + createdAtUtc: DateTime.now().toUtc().toIso8601String(), + updatedAtUtc: DateTime.now().toUtc().toIso8601String(), + ), + ); + }); + + tearDown(() async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(platformChannel, null); + await database.close(); + }); + + AndroidNotificationService service({bool exact = false}) => + AndroidNotificationService( + database: database, + settings: () => settings, + backend: plugin, + exactAlarmCapability: () async => exact, + ); + + test( + 'reconciliation preserves a delivered notification still visible', + () async { + await _insertReminder( + database, + generation: 'generation-1', + scheduledAt: DateTime.now().subtract(const Duration(hours: 1)), + ); + await _insertMapping( + database, + generation: 'generation-1', + platformId: 42, + state: androidNotificationRegistrationState( + settings: settings, + exact: false, + ), + ); + plugin.activeIds.add(42); + final notifications = service(); + await notifications.initialize(timeZoneId: 'America/Vancouver'); + + await notifications.reconcile(); + + expect(plugin.cancelledIds, isNot(contains(42))); + expect(plugin.scheduledIds, isNot(contains(42))); + expect( + await database.select(database.androidNotificationMappings).get(), + hasLength(1), + ); + }, + ); + + test('a stale active generation is canceled without reposting', () async { + await _insertReminder( + database, + generation: 'generation-2', + scheduledAt: DateTime.now().subtract(const Duration(hours: 1)), + ); + await _insertMapping( + database, + generation: 'generation-1', + platformId: 42, + state: androidNotificationRegistrationState( + settings: settings, + exact: false, + ), + ); + plugin.activeIds.add(42); + final notifications = service(); + await notifications.initialize(timeZoneId: 'Etc/UTC'); + + await notifications.reconcile(); + + expect(plugin.cancelledIds, contains(42)); + expect(plugin.scheduledIds, isEmpty); + expect( + await database.select(database.androidNotificationMappings).get(), + isEmpty, + ); + }); + + test( + 'dismissed reminders are removed even when the old post is active', + () async { + await _insertReminder( + database, + generation: 'generation-1', + scheduledAt: DateTime.now().subtract(const Duration(hours: 1)), + dismissed: true, + ); + await _insertMapping( + database, + generation: 'generation-1', + platformId: 42, + state: androidNotificationRegistrationState( + settings: settings, + exact: false, + ), + ); + plugin.activeIds.add(42); + final notifications = service(); + await notifications.initialize(timeZoneId: 'Etc/UTC'); + + await notifications.reconcile(); + + expect(plugin.cancelledIds, contains(42)); + expect( + await database.select(database.androidNotificationMappings).get(), + isEmpty, + ); + }, + ); + + test('an overdue inexact alarm remains registered while pending', () async { + final scheduledAt = DateTime.now().subtract(const Duration(minutes: 30)); + await _insertReminder( + database, + generation: 'generation-1', + scheduledAt: scheduledAt, + ); + final state = androidNotificationRegistrationState( + settings: settings, + exact: false, + ); + await _insertMapping( + database, + generation: 'generation-1', + platformId: 42, + state: state, + scheduledAt: scheduledAt, + ); + plugin.pendingIds.add(42); + final notifications = service(); + await notifications.initialize(timeZoneId: 'America/Vancouver'); + + await notifications.reconcile(); + + expect(plugin.cancelledIds, isNot(contains(42))); + expect(plugin.scheduledIds, isNot(contains(42))); + }); + + test('snooze generation schedules its future effective time', () async { + final now = DateTime.now(); + await database + .into(database.notificationSchedule) + .insert( + NotificationScheduleCompanion.insert( + id: 'reminder-1', + generation: const Value('generation-1:snooze'), + accountId: 'google:test', + sourceType: 'event', + sourceId: 'event-1', + scheduledAtUtc: now + .subtract(const Duration(hours: 1)) + .millisecondsSinceEpoch, + snoozedUntilUtc: Value( + now.add(const Duration(hours: 1)).millisecondsSinceEpoch, + ), + title: 'Snoozed meeting', + createdAtLocal: 0, + updatedAtLocal: 0, + ), + ); + final notifications = service(); + await notifications.initialize(timeZoneId: 'America/Vancouver'); + + await notifications.reconcile(); + + expect(plugin.scheduledIds, hasLength(1)); + final mapping = await database + .select(database.androidNotificationMappings) + .getSingle(); + expect(mapping.generation, 'generation-1:snooze'); + expect( + mapping.scheduledAtUtc, + now.add(const Duration(hours: 1)).millisecondsSinceEpoch, + ); + }); + + test('quiet-hours deferral survives a second reconciliation', () async { + final tomorrow = DateTime.now().add(const Duration(days: 1)); + final requested = DateTime(tomorrow.year, tomorrow.month, tomorrow.day, 23); + settings = settings.copyWith( + quietHoursEnabled: true, + quietHoursStart: '22:00', + quietHoursEnd: '07:00', + ); + await _insertReminder( + database, + generation: 'generation-1', + scheduledAt: requested, + ); + final notifications = service(); + await notifications.initialize(timeZoneId: 'America/Vancouver'); + + await notifications.reconcile(); + final mapping = await database + .select(database.androidNotificationMappings) + .getSingle(); + expect( + mapping.scheduledAtUtc, + greaterThan(requested.millisecondsSinceEpoch), + ); + final platformId = mapping.platformId; + plugin.cancelledIds.clear(); + plugin.scheduled.clear(); + + await notifications.reconcile(); + + expect(plugin.cancelledIds, isNot(contains(platformId))); + expect(plugin.scheduledIds, isNot(contains(platformId))); + }); + + test( + 'overdue pending summary is replaced after privacy and precision change', + () async { + await _insertDueTodayTask(database); + final dueTask = await database.select(database.tasks).getSingle(); + expect(taskDueAsLocal(dueTask), isNotNull); + expect(taskDueAsLocal(dueTask)!.day, DateTime.now().day); + final previousSettings = settings; + final localDate = _today(); + await database + .into(database.androidDailySummarySchedules) + .insert( + AndroidDailySummarySchedulesCompanion.insert( + localDate: localDate, + platformId: 0x425903, + generation: 'old', + scheduledAtUtc: DateTime.now() + .subtract(const Duration(hours: 1)) + .millisecondsSinceEpoch, + taskCount: 1, + state: Value( + androidNotificationRegistrationState( + settings: previousSettings, + exact: false, + ), + ), + updatedAtUtc: 0, + ), + ); + plugin.pendingIds.add(0x425903); + plugin.activeQueryFails = true; + settings = settings.copyWith( + notifyDueToday: true, + notificationDetailLevel: NotificationDetailLevel.private, + ); + final notifications = service(exact: true); + await notifications.initialize(timeZoneId: 'America/Vancouver'); + + await notifications.reconcile(); + + expect(plugin.cancelledIds, contains(0x425903)); + expect(plugin.scheduledIds, contains(0x425903)); + final call = plugin.scheduled.lastWhere((value) => value.id == 0x425903); + expect(call.body, isNull); + expect(call.mode, AndroidScheduleMode.exactAllowWhileIdle); + final row = await database + .select(database.androidDailySummarySchedules) + .getSingle(); + expect( + row.state, + androidNotificationRegistrationState(settings: settings, exact: true), + ); + }, + ); +} + +Future _insertReminder( + AppDatabase database, { + required String generation, + required DateTime scheduledAt, + bool dismissed = false, +}) => database + .into(database.notificationSchedule) + .insert( + NotificationScheduleCompanion.insert( + id: 'reminder-1', + generation: Value(generation), + accountId: 'google:test', + sourceType: 'event', + sourceId: 'event-1', + scheduledAtUtc: scheduledAt.millisecondsSinceEpoch, + title: 'Visible meeting', + dismissedAtUtc: dismissed + ? Value(DateTime.now().millisecondsSinceEpoch) + : const Value.absent(), + createdAtLocal: 0, + updatedAtLocal: 0, + ), + ); + +Future _insertMapping( + AppDatabase database, { + required String generation, + required int platformId, + required String state, + DateTime? scheduledAt, +}) => database + .into(database.androidNotificationMappings) + .insert( + AndroidNotificationMappingsCompanion.insert( + scheduleId: 'reminder-1', + generation: generation, + platformId: platformId, + scheduledAtUtc: + (scheduledAt ?? DateTime.now().subtract(const Duration(hours: 1))) + .millisecondsSinceEpoch, + state: Value(state), + updatedAtUtc: 0, + ), + ); + +Future _insertDueTodayTask(AppDatabase database) async { + await database + .into(database.taskLists) + .insert( + TaskListsCompanion.insert( + accountId: 'google:test', + id: 'list-1', + title: 'Tasks', + rawJson: '{}', + createdLocalAtUtc: '', + updatedLocalAtUtc: '', + ), + ); + final today = DateTime.now(); + await database + .into(database.tasks) + .insert( + TasksCompanion.insert( + accountId: 'google:test', + taskListId: 'list-1', + id: 'task-1', + title: 'Due today', + dueUtc: Value( + DateTime(today.year, today.month, today.day, 12).toIso8601String(), + ), + rawJson: '{}', + createdLocalAtUtc: '', + updatedLocalAtUtc: '', + ), + ); +} + +String _today() { + final now = DateTime.now(); + return '${now.year.toString().padLeft(4, '0')}-' + '${now.month.toString().padLeft(2, '0')}-' + '${now.day.toString().padLeft(2, '0')}'; +} + +class _StatefulNotificationPlugin implements AndroidNotificationBackend { + final Set pendingIds = {}; + final Set activeIds = {}; + final List cancelledIds = []; + final List<({int id, String? body, AndroidScheduleMode mode})> scheduled = []; + bool activeQueryFails = false; + + Iterable get scheduledIds => scheduled.map((value) => value.id); + + @override + Future initialize({ + required InitializationSettings settings, + DidReceiveNotificationResponseCallback? onDidReceiveNotificationResponse, + DidReceiveBackgroundNotificationResponseCallback? + onDidReceiveBackgroundNotificationResponse, + }) async => true; + + @override + Future + getNotificationAppLaunchDetails() async => + const NotificationAppLaunchDetails(false); + + @override + Future> + pendingNotificationRequests() async => [ + for (final id in pendingIds) + PendingNotificationRequest(id, null, null, null), + ]; + + @override + Future> getActiveNotifications() async { + if (activeQueryFails) throw StateError('active query unavailable'); + return [for (final id in activeIds) ActiveNotification(id: id)]; + } + + @override + Future cancel({required int id, String? tag}) async { + cancelledIds.add(id); + pendingIds.remove(id); + activeIds.remove(id); + } + + @override + Future zonedSchedule({ + required int id, + required tz.TZDateTime scheduledDate, + required NotificationDetails notificationDetails, + required AndroidScheduleMode androidScheduleMode, + String? title, + String? body, + String? payload, + DateTimeComponents? matchDateTimeComponents, + }) async { + scheduled.add((id: id, body: body, mode: androidScheduleMode)); + pendingIds.add(id); + } + + @override + Future show({ + required int id, + String? title, + String? body, + required NotificationDetails notificationDetails, + }) async {} + + @override + Future requestNotificationPermission() async => true; + + @override + Future requestExactAlarmPermission() async => true; + + @override + Future canScheduleExactly() async => false; +} From f14f309f0357712f878b53df7b2643105996684a Mon Sep 17 00:00:00 2001 From: albert Date: Mon, 14 Sep 2026 20:33:32 -0700 Subject: [PATCH 06/25] Introduce detailed free/busy evaluations: - Added `FreeBusyEvaluationStatus` enum and `FreeBusyCalendarResultDto` model. - Introduced `DetailedFreeBusyClient` interface for per-calendar free/busy evaluation. - Updated Google Calendar client to support detailed free/busy responses with error handling. - Refactored Android notifications to include hashed `title` and `body` in registration state logic. - Enhanced `android_availability_dialog` to display detailed free/busy results per recipient. - Adjusted layout rendering for compact screens, added constraints for all-day sections and scrolling improvements. --- docs/android_provider_parity.md | 2 +- docs/android_verification.md | 18 +- lib/src/android/android_notifications.dart | 61 ++-- .../android_availability_dialog.dart | 54 ++-- .../presentation/android_schedule_screen.dart | 290 ++++++++++-------- .../calendar_providers/calendar_sync_dto.dart | 22 ++ .../cloud_calendar_client.dart | 10 + .../google_calendar_api_client.dart | 94 +++++- .../google_calendar_api_client_test.dart | 47 +++ .../android/android_adaptive_app_test.dart | 179 ++++++++++- .../android_notification_policy_test.dart | 57 ++-- ...roid_notification_reconciliation_test.dart | 64 +++- 12 files changed, 677 insertions(+), 221 deletions(-) diff --git a/docs/android_provider_parity.md b/docs/android_provider_parity.md index 0f740685..30853f33 100644 --- a/docs/android_provider_parity.md +++ b/docs/android_provider_parity.md @@ -18,7 +18,7 @@ selected concrete collection. | Workflow | Visible route | Capability and permission gate | Input and result behavior | |---|---|---|---| | Invitation response | Schedule item detail > **Accept**, **Tentative**, or **Decline** | The current user must be an attendee. Google and Microsoft use their existing response mutations; Nextcloud also requires scheduling reply capability and asks for occurrence/series scope when applicable. | Updates the shared local event projection, queues the account-specific calendar sync, and reports a redacted failure without inventing support for Apple or WebCal. | -| Guest availability | Event editor > **Check guest availability** | A valid start/end and at least one guest are required. Google uses the shared free/busy API. Nextcloud additionally requires a DAV collection with `canQueryFreeBusy`. Microsoft, Apple, and WebCal do not show the action. | Queries the edited guest set and draft interval, then shows a bounded per-recipient free/busy result with refresh and error states. It does not mutate the event. | +| Guest availability | Event editor > **Check guest availability** | A valid start/end and at least one guest are required. Google uses the shared free/busy API and preserves each requested calendar's success, failure, or missing-result state. Nextcloud additionally requires a DAV collection with `canQueryFreeBusy`. Microsoft, Apple, and WebCal do not show the action. | Queries the edited guest set and draft interval, then shows a bounded per-recipient result with refresh and error states. **Free** is shown only for an explicit successful result with no busy intervals; partial failures and omitted results are **Unknown**. It does not mutate the event. | | Nextcloud scheduling inbox | Settings > a Nextcloud calendar > **Scheduling inbox** | Requires a Nextcloud DAV calendar; server scheduling capability and responses are enforced by the shared service. | Loads cached then refreshed inbox messages. Acknowledgement is explicitly confirmed and removes the inbox message, not the calendar event. | | Calendar administration | Settings > calendar > rename/color/delete | Concrete source capabilities determine which actions appear. Queued Google/Microsoft mutations use the existing account-specific pending-calendar sync requester; online DAV administration stays on its shared service path. | Optimistic local changes retain the normal pending/error state and are not duplicated by a second sync subsystem. | | Diagnostics | Settings > **Diagnostics** | Always local; individual retry/discard actions require a concrete blocked operation. | Shows account/auth and last-sync state, reminder precision, and live blocked operations. Retry is explicit; discard requires confirmation; diagnostic errors are redacted according to Settings. | diff --git a/docs/android_verification.md b/docs/android_verification.md index 0f91d02a..78e5042a 100644 --- a/docs/android_verification.md +++ b/docs/android_verification.md @@ -31,12 +31,12 @@ test is never counted as a pass. | PASSED | Repository-wide formatting | bundled `dart format --output=none --set-exit-if-changed .` | All 719 Dart files were already formatted; the check made no changes. | | PASSED | Repository-wide static analysis | `flutter analyze` | `No issues found!` for the current correction working tree. | | PASSED | Platform boundary enforcement | bundled `dart run tool/check_platform_boundaries.dart` | Current Android foreground/headless composition contains no prohibited desktop imports. | -| PASSED | Repository-wide unit and widget suite | `flutter test --concurrency=1 --reporter compact` | 2,271 tests passed and 10 explicitly environment-gated tests were skipped in the current correction working tree. The skipped live-integration cases are not counted as passes. | -| PASSED | Android platform test suite | `flutter test test/platform/android --concurrency=1` | 66 current Android unit/widget tests passed in one serialized run. This includes the scenario-specific rows below; it is not counted again as an additional 66 scenarios. | +| PASSED | Repository-wide unit and widget suite | `flutter test --concurrency=1 --reporter compact` | 2,275 tests passed and 10 explicitly environment-gated tests were skipped in the current correction working tree. The skipped live-integration cases are not counted as passes. | +| PASSED | Android platform test suite | `flutter test test/platform/android --concurrency=1` | 69 current Android unit/widget tests passed in one serialized run. This includes the scenario-specific rows below; it is not counted again as an additional 69 scenarios. | | PASSED | Affected shared regression set | `flutter test` over calendar collection/repository, pending sync requester, Nextcloud scheduling service/policy, and feedback submission/presentation test files with `--concurrency=1` | 100 tests passed. These verify shared payload/projection/service behavior used by the new Android routes; they do not turn widget navigation into a live-provider round trip. | -| PASSED | Android adaptive widget matrix | `flutter test test/platform/android/android_adaptive_app_test.dart --concurrency=1` | 29 widget tests passed. The empty shell matrix covers the required sizes/themes/RTL. Populated flows explicitly select Day, Week, Month, and Year, exercise all-day/date-only overflow at compact and wide Week widths, submit and inspect an invitation response, and render Month/Year at 320x500 and 200% text. System Back is exercised independently for every named event/task text field. Stateful secure-storage tests round-trip changed event destinations, recurring scope, and advanced task recurrence. Dirty task save-and-move, cancel, and failure paths inspect real database/recovery state. Settings routes, Apple credential-prompt lifecycle, guest-availability error UI, Google calendar rename/color/delete sync requests, and feedback failure/retry identity are exercised. | -| PASSED | Android notification policy | `flutter test test/platform/android/android_notification_policy_test.dart --concurrency=1` | 8 helper/contract tests passed: stable IDs, quiet-hours eligibility, pending/displayed retention predicates, privacy/exact fingerprinting, explicit Open UI action, and payload parsing. | -| PASSED | Android notification reconciliation | `flutter test test/platform/android/android_notification_reconciliation_test.dart --concurrency=1` | 7 service tests passed against database rows and a stateful pending/active platform fake: displayed retention, stale generation cancellation, Dismiss, overdue inexact pending retention, Snooze, quiet-hours deferral, and overdue daily-summary privacy/precision replacement. | +| PASSED | Android adaptive widget matrix | `flutter test test/platform/android/android_adaptive_app_test.dart --concurrency=1` | 31 widget tests passed. The empty shell matrix covers the required sizes/themes/RTL. Populated flows explicitly select Day, Week, Month, and Year, exercise all-day/date-only overflow at compact and wide Week widths, submit and inspect an invitation response, and render Month/Year at 320x500 and 200% text. Long task details retain their full notes and scroll to the Edit action at ordinary-phone, 700x320 landscape, and 200% text constraints. System Back is exercised independently for every named event/task text field. Stateful secure-storage tests restore a new event's saved destination and time even when the editor reopens on another date, and round-trip recurring scope and advanced task recurrence. Google partial free/busy failures and omitted recipient results render Unknown while an explicit successful empty result renders Free. Dirty task save-and-move, cancel, and failure paths inspect real database/recovery state. Settings routes, Apple credential-prompt lifecycle, Google calendar rename/color/delete sync requests, and feedback failure/retry identity are exercised. | +| PASSED | Android notification policy | `flutter test test/platform/android/android_notification_policy_test.dart --concurrency=1` | 8 helper/contract tests passed: stable IDs, quiet-hours eligibility, pending/displayed retention predicates, privacy/exact/content fingerprinting, explicit Open UI action, and payload parsing. | +| PASSED | Android notification reconciliation | `flutter test test/platform/android/android_notification_reconciliation_test.dart --concurrency=1` | 8 service tests passed against database rows and a stateful pending/active platform fake: displayed retention, stale generation cancellation, Dismiss, overdue inexact pending retention, Snooze, quiet-hours deferral, content-only pending replacement, and overdue daily-summary privacy/precision replacement. | | PASSED | Android authorization regression | `flutter test test/platform/android/android_authorization_broker_test.dart` | Missing native scopes are not promoted to grants, native interaction-required becomes the shared reconnect error, and non-revoking local Google removal clears its native binding | | PASSED | Android attendee mutation regression | `flutter test test/platform/android/android_event_attendee_edit_test.dart` | Unchanged guest text preserves the original structured attendee objects and change flag; additions retain existing guest metadata | | PASSED | Android contextual LAN regression | `flutter test test/platform/android/android_local_network_host_test.dart --concurrency=1` | 6 tests passed for local suffixes and literals plus injected DNS resolution of ordinary dotted names to private, public, mixed, and failed results. These are resolver/onboarding-policy tests, not an Android 17 socket-permission device test. | @@ -96,7 +96,7 @@ provider account, or owner registration. |---|---|---| | PASSED | Bootstrap and navigation — automated | Empty/populated/error/offline repository states, adaptive navigation, selected dates/filters, and cold/warm activation routing are covered by the full suite and Android widget/channel tests. | | BLOCKED | Bootstrap and navigation — installed app | Fresh/returning install, process restoration, and cold/warm file/notification launches require an Android device and installed APK. | -| PASSED | Google — implementation/contract | Native `AuthorizationClient`, explicit selection, exact BusyMax/native binding, provider-reported grant persistence, required-scope validation, reconnect error mapping, local binding removal, Play-services availability, Calendar/Tasks repository reuse, and no Android desktop OAuth imports are implemented and contract-tested. | +| PASSED | Google — implementation/contract | Native `AuthorizationClient`, explicit selection, exact BusyMax/native binding, provider-reported grant persistence, required-scope validation, reconnect error mapping, local binding removal, Play-services availability, Calendar/Tasks repository reuse, per-calendar free/busy success/failure/missing results, and no Android desktop OAuth imports are implemented and contract-tested. | | BLOCKED | Google — live | Two disposable Google accounts, enabled Calendar/Tasks APIs, and Android OAuth registrations for the debug, local-release and Play app-signing certificate fingerprints are required to exercise consent/cancel/deny/expiry/reconnect, real data, removal, and foreground/headless silent acquisition. | | PASSED | Microsoft — implementation/contract | MSAL multiple-account browser flow, exact native-account binding, provider-reported grant persistence, account-specific silent acquisition, domain reconnect mapping, independent reminder date/time editing, and removal isolation are implemented. Native unit tests distinguish interaction-required from service/network/configuration failures; live account recovery is not implied. | | BLOCKED | Microsoft — live | A real public client ID/tenant, signer-specific debug/local/Play redirects, disposable personal and organizational accounts, and an Android device are required for consent/browser return/silent worker/removal tests. | @@ -104,13 +104,13 @@ provider account, or owner registration. | BLOCKED | Nextcloud — live | A disposable public-HTTPS server account plus an owner-configured LAN endpoint on an Android 17 device are required to verify polling/resume, app-password storage, real calendars/tasks/sharing/publishing/trash/admin and denied-LAN behavior. | | PASSED | Apple and WebCal — automated | Calendar-only/read-only capability enforcement, credential separation, subscription refresh state and meaningful account failure state use the existing tested repositories and Android mobile UI. | | BLOCKED | Apple and WebCal — live | Disposable iCloud credentials with an app-specific password and disposable WebCal endpoints are required to verify revocation, refresh, and private embedded credentials against real services. | -| PASSED | Calendar and tasks — automated | Virtual Agenda, time-axis Day/Week with bounded accessible all-day overflow, scrollable civil-date Month with selected-day agenda, and Year miniatures are implemented. Current populated Android fixtures verify overnight intersection, source/list hiding, Week selection, invitation controls, all-day overflow, and short/large-text Month/Year. Editor tests cover read-only controls, account-qualified destinations, every named text field's system-Back guard, versioned recovery identity/recurrence/scope, and dirty task moves. Shared provider payload tests remain a separate evidence layer. | +| PASSED | Calendar and tasks — automated | Virtual Agenda, time-axis Day/Week with bounded accessible all-day overflow, scrollable civil-date Month with selected-day agenda, and Year miniatures are implemented. Current populated Android fixtures verify overnight intersection, source/list hiding, Week selection, invitation controls, all-day overflow, short/large-text Month/Year, and full scrollable details at phone, short-landscape, and enlarged-text constraints. Editor tests cover read-only controls, account-qualified destinations, every named text field's system-Back guard, versioned recovery identity across changed creation defaults, recurrence/scope, and dirty task moves. Shared provider payload tests remain a separate evidence layer. | | BLOCKED | Calendar and tasks — service round trips | Named destructive workflows for Google, Microsoft, Nextcloud, Apple and WebCal require provider registrations and dedicated QA collections. No owner's real collection was modified. | | PASSED | Offline and conflicts — automated | Cached browsing, supported queued mutations, replay/backoff, ETags, tombstones, preserved ICS properties, interrupted/ambiguous creates and serialized UI/worker account access are covered by the shared suite and account gate. | | BLOCKED | Offline and conflicts — real network | Provider-side state after a dropped create response and reconnect must be observed using disposable remote collections and controlled networking on an Android device. | | PASSED | Database and preferences — automated | Schema migration through version 18, foreground/background visibility, atomic settings writes, account removal, engine-owned coordination release and account resurrection protections are covered by database, settings, sync and native gate tests. | | BLOCKED | Database and preferences — OS interruption | Kill-during-write, upgrade, and account removal while a real WorkManager job owns the native process require an installed app and device instrumentation. | -| PASSED | Notifications — automated | Durable mappings and stateful reconciliation tests distinguish pending, displayed, dismissed, and stale-generation notifications. They cover quiet-hours deferral, overdue inexact retention, Snooze/Dismiss, explicit Open UI behavior, and overdue daily-summary privacy/precision replacement. Other shared tests cover horizon/cap and mutation-driven rebuilds. Platform delivery, permission recovery, and refill lifecycle remain device rows; future scheduling is not labeled delivery. | +| PASSED | Notifications — automated | Durable mappings and stateful reconciliation tests distinguish pending, displayed, dismissed, and stale-generation notifications. They cover quiet-hours deferral, overdue inexact retention, Snooze/Dismiss, explicit Open UI behavior, content-only pending replacement using the effective public/private notification text, and overdue daily-summary privacy/precision replacement. Other shared tests cover horizon/cap and mutation-driven rebuilds. Platform delivery, permission recovery, and refill lifecycle remain device rows; future scheduling is not labeled delivery. | | BLOCKED | Notifications — delivered behavior | A physical phone and signed installed build are required for screen-off timing, process death, permission/channel denial/revocation, reboot, package upgrade, battery idle, exact/inexact observation and cold/warm actions. | | PASSED | Time and recurrence — automated | Shared timezone, DST gap/overlap, all-day/date-only/floating fields, recurrence and exception tests pass; Android system timezone/12–24-hour channel updates feed the app scope. | | BLOCKED | Time and recurrence — system changes | A device is required to change clock/timezone/locale/12–24-hour settings and verify alarm rescheduling across provider/device timezone differences. | @@ -132,7 +132,7 @@ provider account, or owner registration. | BLOCKED | Rotation/split screen/background/foreground | Widget constraints passed, but the real Activity lifecycle needs an Android device. | | BLOCKED | Ordinary process death/reboot | WorkManager, alarm restoration and database invalidation must be observed after real process removal and reboot. | | BLOCKED | Force-stop | A device is required. Expected behavior is explicitly limited: Android does not guarantee work or alarms after user force-stop until the app is started again. | -| PASSED | Logical viewport rendering | The empty-shell matrix covers required logical sizes, themes, scales, RTL, and every primary destination. The populated matrix separately covers Day/Week data and short 320x500/200% Month/Year without framework exceptions. This is logical widget rendering, not a device screenshot. | +| PASSED | Logical viewport rendering | The empty-shell matrix covers required logical sizes, themes, scales, RTL, and every primary destination. The populated matrix separately covers Day/Week data, short 320x500/200% Month/Year, and long scrollable details at 412x915, 700x320, and 200% text without framework exceptions. This is logical widget rendering, not a device screenshot. | | PASSED | Automated accessibility | Widget tests verify labelled navigation and accessible all-day overflow, and exercise large text without clamping. Touch-target and color/contrast statements remain source/widget checks rather than TalkBack or physical-device certification. | | BLOCKED | Actual Android screenshots | No Android device existed, so no device screenshots were produced. Linux screenshots are intentionally not presented as Android evidence. Required scenes are mixed schedule, selected-day month agenda, timed overlaps, tasks with undated/completed items, keyboard-visible event editor, account selection, settings, and loading/error/offline states using QA data. | | BLOCKED | TalkBack/traversal/contrast | Requires a device or emulator with Android accessibility services and manual observation at all named screens. | diff --git a/lib/src/android/android_notifications.dart b/lib/src/android/android_notifications.dart index cf5bd60d..1ea0122c 100644 --- a/lib/src/android/android_notifications.dart +++ b/lib/src/android/android_notifications.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'dart:convert'; import 'package:busymax_android_platform/busymax_android_platform.dart'; +import 'package:crypto/crypto.dart'; import 'package:drift/drift.dart'; import 'package:flutter_local_notifications/flutter_local_notifications.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -492,10 +493,6 @@ final class AndroidNotificationService activeIds = const {}; } final exact = await canScheduleExactly(); - final registrationState = androidNotificationRegistrationState( - settings: settings, - exact: exact, - ); final rows = await (_database.select(_database.notificationSchedule)..where( (table) => @@ -562,8 +559,23 @@ final class AndroidNotificationService final usedIds = { for (final mapping in activeMappings.values) mapping.platformId, }; + final strings = _strings(); for (final row in desired) { final effectiveAt = applyAndroidQuietHours(_effectiveAt(row), settings); + final title = + settings.notificationDetailLevel == NotificationDetailLevel.private + ? strings.privateTitle + : row.title; + final body = + settings.notificationDetailLevel == NotificationDetailLevel.private + ? null + : row.body; + final registrationState = androidNotificationRegistrationState( + settings: settings, + exact: exact, + title: title, + body: body, + ); var mapping = activeMappings[row.id]; final sameGeneration = mapping?.generation == row.generation; final displayed = @@ -592,20 +604,13 @@ final class AndroidNotificationService accountId: row.accountId, sourceId: row.sourceId, ).encode(); - final strings = _strings(); final scheduledAt = effectiveAt <= nowMillis ? now.add(const Duration(seconds: 5)).millisecondsSinceEpoch : effectiveAt; await _notifications.zonedSchedule( id: platformId, - title: - settings.notificationDetailLevel == NotificationDetailLevel.private - ? strings.privateTitle - : row.title, - body: - settings.notificationDetailLevel == NotificationDetailLevel.private - ? null - : row.body, + title: title, + body: body, scheduledDate: tz.TZDateTime.fromMillisecondsSinceEpoch( tz.local, scheduledAt, @@ -648,7 +653,6 @@ final class AndroidNotificationService settings: settings, eligibleAccounts: eligibleAccounts, exact: exact, - registrationState: registrationState, pendingIds: pendingIds, activeIds: activeIds, ); @@ -658,7 +662,6 @@ final class AndroidNotificationService required AppSettings settings, required Set eligibleAccounts, required bool exact, - required String registrationState, required Set pendingIds, required Set activeIds, }) async { @@ -723,6 +726,18 @@ final class AndroidNotificationService )..where((table) => table.localDate.equals(localDate))).go(); return; } + final strings = _strings(); + final title = strings.dueTodayTitle; + final body = + settings.notificationDetailLevel == NotificationDetailLevel.private + ? null + : strings.dueTodayBody(count); + final registrationState = androidNotificationRegistrationState( + settings: settings, + exact: exact, + title: title, + body: body, + ); final current = existing .where((row) => row.localDate == localDate) .firstOrNull; @@ -766,13 +781,10 @@ final class AndroidNotificationService if (unchanged) return; if (current != null) await _notifications.cancel(id: platformId); final generation = '$localDate:$count:$scheduledAt'; - final strings = _strings(); await _notifications.zonedSchedule( id: platformId, - title: strings.dueTodayTitle, - body: settings.notificationDetailLevel == NotificationDetailLevel.private - ? null - : strings.dueTodayBody(count), + title: title, + body: body, scheduledDate: tz.TZDateTime.fromMillisecondsSinceEpoch( tz.local, scheduledAt, @@ -877,8 +889,13 @@ int applyAndroidQuietHours(int epochMillis, AppSettings settings) { String androidNotificationRegistrationState({ required AppSettings settings, required bool exact, -}) => - 'scheduled:v2:${settings.notificationDetailLevel.name}:${exact ? 'exact' : 'inexact'}'; + String? title, + String? body, +}) { + final contentHash = sha256.convert(utf8.encode(jsonEncode([title, body]))); + return 'scheduled:v3:${settings.notificationDetailLevel.name}:' + '${exact ? 'exact' : 'inexact'}:$contentHash'; +} AndroidNotificationAction androidOpenNotificationAction( AndroidNotificationStrings strings, diff --git a/lib/src/android/presentation/android_availability_dialog.dart b/lib/src/android/presentation/android_availability_dialog.dart index 93a12255..785da5b7 100644 --- a/lib/src/android/presentation/android_availability_dialog.dart +++ b/lib/src/android/presentation/android_availability_dialog.dart @@ -6,6 +6,7 @@ import 'package:intl/intl.dart'; import '../../app/app_bootstrap.dart'; import '../../calendar_providers/calendar_sync_dto.dart'; +import '../../calendar_providers/cloud_calendar_client.dart'; import '../../core/logging/redacting_logger.dart'; import '../../dav/nextcloud/nextcloud_scheduling_service.dart'; import '../../features/calendar/presentation/event_editor_draft.dart'; @@ -53,7 +54,7 @@ class _AndroidGuestAvailabilityDialogState bool _loading = true; Object? _error; List _nextcloud = const []; - Map> _google = const {}; + Map _google = const {}; List get _recipients => widget.draft.attendees .where((attendee) => !attendee.self && !attendee.organizer) @@ -91,20 +92,35 @@ class _AndroidGuestAvailabilityDialogState if (client == null || start == null || end == null) { throw StateError('Availability is unavailable'); } - final slots = await client.freeBusy( - calendarIds: _recipients, - rangeStart: start.toUtc(), - rangeEnd: end.toUtc(), - ); - _google = { - for (final recipient in _recipients) - recipient: slots - .where( - (slot) => - slot.calendarId.toLowerCase() == recipient.toLowerCase(), - ) - .toList(growable: false), - }; + if (client is DetailedFreeBusyClient) { + final results = await (client as DetailedFreeBusyClient) + .freeBusyDetails( + calendarIds: _recipients, + rangeStart: start.toUtc(), + rangeEnd: end.toUtc(), + ); + final byCalendar = { + for (final result in results) + result.calendarId.toLowerCase(): result, + }; + _google = { + for (final recipient in _recipients) + recipient: + byCalendar[recipient.toLowerCase()] ?? + FreeBusyCalendarResultDto( + calendarId: recipient, + status: FreeBusyEvaluationStatus.missing, + ), + }; + } else { + _google = { + for (final recipient in _recipients) + recipient: FreeBusyCalendarResultDto( + calendarId: recipient, + status: FreeBusyEvaluationStatus.missing, + ), + }; + } } else { throw StateError('Availability is unsupported'); } @@ -153,10 +169,12 @@ class _AndroidGuestAvailabilityDialogState for (final entry in _google.entries) _AvailabilityEntry( recipient: entry.key, - free: entry.value.isEmpty, - unknown: false, + free: + entry.value.succeeded && + entry.value.busySlots.isEmpty, + unknown: !entry.value.succeeded, intervals: [ - for (final value in entry.value) + for (final value in entry.value.busySlots) interval(value.start, value.end), ], ), diff --git a/lib/src/android/presentation/android_schedule_screen.dart b/lib/src/android/presentation/android_schedule_screen.dart index 3eefc8a4..bee6bdd0 100644 --- a/lib/src/android/presentation/android_schedule_screen.dart +++ b/lib/src/android/presentation/android_schedule_screen.dart @@ -385,120 +385,126 @@ class _AndroidScheduleScreenState extends ConsumerState { showDragHandle: true, isScrollControlled: true, builder: (sheetContext) => SafeArea( - child: Padding( - padding: const EdgeInsets.fromLTRB(20, 0, 20, 24), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Text( - item.title, - style: Theme.of(context).textTheme.headlineSmall, - ), - const SizedBox(height: 8), - Text(item.sourceName ?? item.provider.displayName), - if (item.start != null) + child: ConstrainedBox( + constraints: BoxConstraints( + maxHeight: MediaQuery.sizeOf(sheetContext).height * .82, + ), + child: SingleChildScrollView( + key: const ValueKey('android-schedule-details-scroll'), + padding: const EdgeInsets.fromLTRB(20, 0, 20, 24), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ Text( - formatClockDateTime( - context, - item.start!, - DateFormat.yMMMd().format(item.start!), - ), - ), - if (item case CalendarScheduleItem( - :final location?, - ) when location.isNotEmpty) - ListTile( - contentPadding: EdgeInsets.zero, - leading: const Icon(Icons.place), - title: Text(location), - trailing: const Icon(Icons.open_in_new), - onTap: () => unawaited( - const ExternalLocationLauncher( - platform: _androidLocationPlatform, - ).open(projectedScheduleItemLocationDestination(item)), - ), - ), - if (item case TaskScheduleItem( - :final notes?, - ) when notes.isNotEmpty) - Padding( - padding: const EdgeInsets.only(top: 12), - child: Text(notes), + item.title, + style: Theme.of(context).textTheme.headlineSmall, ), - if (item is CalendarScheduleItem && - item.canRespondToInvitation) ...[ - const SizedBox(height: 16), - Wrap( - spacing: 8, - runSpacing: 8, - children: [ - FilledButton.tonal( - onPressed: () { - Navigator.pop(sheetContext); - unawaited( - _respondToInvitation( - item, - CalendarInvitationResponse.accept, - ), - ); - }, - child: Text(context.l10n.acceptInvitation), + const SizedBox(height: 8), + Text(item.sourceName ?? item.provider.displayName), + if (item.start != null) + Text( + formatClockDateTime( + context, + item.start!, + DateFormat.yMMMd().format(item.start!), ), - OutlinedButton( - onPressed: () { - Navigator.pop(sheetContext); - unawaited( - _respondToInvitation( - item, - CalendarInvitationResponse.tentative, - ), - ); - }, - child: Text(context.l10n.tentativeInvitation), + ), + if (item case CalendarScheduleItem( + :final location?, + ) when location.isNotEmpty) + ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.place), + title: Text(location), + trailing: const Icon(Icons.open_in_new), + onTap: () => unawaited( + const ExternalLocationLauncher( + platform: _androidLocationPlatform, + ).open(projectedScheduleItemLocationDestination(item)), ), - OutlinedButton( - onPressed: () { - Navigator.pop(sheetContext); - unawaited( - _respondToInvitation( - item, - CalendarInvitationResponse.decline, - ), - ); - }, - child: Text(context.l10n.declineInvitation), + ), + if (item case TaskScheduleItem( + :final notes?, + ) when notes.isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: 12), + child: Text(notes), + ), + if (item is CalendarScheduleItem && + item.canRespondToInvitation) ...[ + const SizedBox(height: 16), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + FilledButton.tonal( + onPressed: () { + Navigator.pop(sheetContext); + unawaited( + _respondToInvitation( + item, + CalendarInvitationResponse.accept, + ), + ); + }, + child: Text(context.l10n.acceptInvitation), + ), + OutlinedButton( + onPressed: () { + Navigator.pop(sheetContext); + unawaited( + _respondToInvitation( + item, + CalendarInvitationResponse.tentative, + ), + ); + }, + child: Text(context.l10n.tentativeInvitation), + ), + OutlinedButton( + onPressed: () { + Navigator.pop(sheetContext); + unawaited( + _respondToInvitation( + item, + CalendarInvitationResponse.decline, + ), + ); + }, + child: Text(context.l10n.declineInvitation), + ), + ], + ), + ], + const SizedBox(height: 16), + if (item.capabilities.canEdit) + FilledButton.icon( + onPressed: () async { + Navigator.pop(sheetContext); + if (item is CalendarScheduleItem) { + await _editEvent(context, item); + } else if (item is TaskScheduleItem) { + await showAndroidTaskEditor(context, ref, task: item); + } + }, + icon: const Icon(Icons.edit), + label: Text( + item is CalendarScheduleItem + ? context.l10n.editEvent + : context.l10n.editTask, ), - ], - ), - ], - const SizedBox(height: 16), - if (item.capabilities.canEdit) - FilledButton.icon( - onPressed: () async { - Navigator.pop(sheetContext); - if (item is CalendarScheduleItem) { - await _editEvent(context, item); - } else if (item is TaskScheduleItem) { - await showAndroidTaskEditor(context, ref, task: item); - } - }, - icon: const Icon(Icons.edit), - label: Text( - item is CalendarScheduleItem - ? context.l10n.editEvent - : context.l10n.editTask, ), - ), - if (item.provider == BusyProvider.nextcloud) ...[ - const SizedBox(height: 8), - OutlinedButton.icon( - onPressed: () => _exportItem(sheetContext, item), - icon: const Icon(Icons.file_download_outlined), - label: Text(context.l10n.export), - ), + if (item.provider == BusyProvider.nextcloud) ...[ + const SizedBox(height: 8), + OutlinedButton.icon( + onPressed: () => _exportItem(sheetContext, item), + icon: const Icon(Icons.file_download_outlined), + label: Text(context.l10n.export), + ), + ], ], - ], + ), ), ), ), @@ -868,6 +874,19 @@ class _AndroidTimeGrid extends StatelessWidget { .clamp(112.0, 220.0) .toDouble(); final contentWidth = _axisWidth + columnWidth * days; + final compactHeight = constraints.maxHeight < 240; + final dateHeaderHeight = compactHeight ? 36.0 : 48.0; + final preferredAllDayHeight = + (MediaQuery.textScalerOf(context).scale(28) + 70) + .clamp(68.0, 148.0) + .toDouble(); + final maximumAllDayHeight = + (constraints.maxHeight - dateHeaderHeight - 32) + .clamp(0.0, double.infinity) + .toDouble(); + final allDayHeight = preferredAllDayHeight + .clamp(0.0, maximumAllDayHeight) + .toDouble(); return SingleChildScrollView( scrollDirection: Axis.horizontal, child: SizedBox( @@ -876,7 +895,7 @@ class _AndroidTimeGrid extends StatelessWidget { child: Column( children: [ SizedBox( - height: 48, + height: dateHeaderHeight, child: Row( children: [ const SizedBox(width: _axisWidth), @@ -894,8 +913,7 @@ class _AndroidTimeGrid extends StatelessWidget { ), ), SizedBox( - height: (MediaQuery.textScalerOf(context).scale(28) + 70) - .clamp(92.0, 148.0), + height: allDayHeight, child: Row( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ @@ -993,24 +1011,27 @@ class _AndroidAllDayCell extends StatelessWidget { ), child: items.isEmpty ? null - : Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - _AndroidCalendarChip(item: items.first, onOpen: onOpen), - if (items.length > 1) - Align( - alignment: AlignmentDirectional.centerEnd, - child: TextButton( - style: TextButton.styleFrom( - minimumSize: const Size(48, 32), - padding: const EdgeInsets.symmetric(horizontal: 4), - textStyle: Theme.of(context).textTheme.labelSmall, + : SingleChildScrollView( + primary: false, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _AndroidCalendarChip(item: items.first, onOpen: onOpen), + if (items.length > 1) + Align( + alignment: AlignmentDirectional.centerEnd, + child: TextButton( + style: TextButton.styleFrom( + minimumSize: const Size(48, 32), + padding: const EdgeInsets.symmetric(horizontal: 4), + textStyle: Theme.of(context).textTheme.labelSmall, + ), + onPressed: () => _showAllDayItems(context), + child: Text(context.l10n.moreItems(items.length - 1)), ), - onPressed: () => _showAllDayItems(context), - child: Text(context.l10n.moreItems(items.length - 1)), ), - ), - ], + ], + ), ), ); @@ -2147,16 +2168,24 @@ class _AndroidEventEditorState extends ConsumerState { if (value is! Map) return; final map = value.cast(); final savedAt = DateTime.tryParse(map['savedAt']?.toString() ?? ''); + final version = map['v']; + final editingExistingEvent = widget.draft.eventId != null; + final storedEventId = map['eventId']?.toString(); + final identityMatches = editingExistingEvent + ? (version == 2 || storedEventId == widget.draft.eventId) && + map['baseTitle'] == widget.draft.title && + map['baseStart'] == widget.draft.start?.toIso8601String() + : storedEventId == null; if (savedAt == null || DateTime.now().difference(savedAt).abs() > const Duration(days: 14) || - map['baseTitle'] != widget.draft.title || - map['baseStart'] != widget.draft.start?.toIso8601String()) { + (version != 2 && version != 3) || + !identityMatches) { await _clearRecovery(); return; } final start = DateTime.tryParse(map['start']?.toString() ?? ''); final end = DateTime.tryParse(map['end']?.toString() ?? ''); - if (map['v'] != 2 || start == null || end == null) { + if (start == null || end == null) { await _clearRecovery(); return; } @@ -2250,8 +2279,9 @@ class _AndroidEventEditorState extends ConsumerState { return; } final payload = jsonEncode({ - 'v': 2, + 'v': 3, 'savedAt': DateTime.now().toIso8601String(), + 'eventId': widget.draft.eventId, 'baseTitle': widget.draft.title, 'baseStart': widget.draft.start?.toIso8601String(), 'accountId': _draft.accountId, diff --git a/lib/src/calendar_providers/calendar_sync_dto.dart b/lib/src/calendar_providers/calendar_sync_dto.dart index 25e3509f..11fd23ea 100644 --- a/lib/src/calendar_providers/calendar_sync_dto.dart +++ b/lib/src/calendar_providers/calendar_sync_dto.dart @@ -148,3 +148,25 @@ class BusySlotDto { final DateTime start; final DateTime end; } + +enum FreeBusyEvaluationStatus { success, failed, missing } + +/// The result of evaluating one requested calendar in a free/busy query. +/// +/// A successful response with no [busySlots] means the calendar is free. A +/// failed or missing response must remain unknown even when it has no slots. +class FreeBusyCalendarResultDto { + const FreeBusyCalendarResultDto({ + required this.calendarId, + required this.status, + this.busySlots = const [], + this.errors = const [], + }); + + final String calendarId; + final FreeBusyEvaluationStatus status; + final List busySlots; + final List errors; + + bool get succeeded => status == FreeBusyEvaluationStatus.success; +} diff --git a/lib/src/calendar_providers/cloud_calendar_client.dart b/lib/src/calendar_providers/cloud_calendar_client.dart index fff45bcf..3c89242a 100644 --- a/lib/src/calendar_providers/cloud_calendar_client.dart +++ b/lib/src/calendar_providers/cloud_calendar_client.dart @@ -99,6 +99,16 @@ abstract interface class CompleteRecurringInstanceClient { }); } +/// Optional capability for providers whose batch free/busy endpoint reports +/// success or failure independently for every requested calendar. +abstract interface class DetailedFreeBusyClient { + Future> freeBusyDetails({ + required List calendarIds, + required DateTime rangeStart, + required DateTime rangeEnd, + }); +} + /// User-specific calendar-list operations supported by providers that expose /// calendar metadata separately from the signed-in user's personalization. abstract interface class CalendarListManagementClient { diff --git a/lib/src/google_calendar/google_calendar_api_client.dart b/lib/src/google_calendar/google_calendar_api_client.dart index bae933ec..e43a1f41 100644 --- a/lib/src/google_calendar/google_calendar_api_client.dart +++ b/lib/src/google_calendar/google_calendar_api_client.dart @@ -15,6 +15,7 @@ class GoogleCalendarApiClient implements CloudCalendarClient, CompleteRecurringInstanceClient, + DetailedFreeBusyClient, CalendarListManagementClient { GoogleCalendarApiClient({ required http.Client httpClient, @@ -501,6 +502,20 @@ class GoogleCalendarApiClient required List calendarIds, required DateTime rangeStart, required DateTime rangeEnd, + }) async { + final results = await freeBusyDetails( + calendarIds: calendarIds, + rangeStart: rangeStart, + rangeEnd: rangeEnd, + ); + return [for (final result in results) ...result.busySlots]; + } + + @override + Future> freeBusyDetails({ + required List calendarIds, + required DateTime rangeStart, + required DateTime rangeEnd, }) async { final json = await _requestJson( 'POST', @@ -515,19 +530,64 @@ class GoogleCalendarApiClient ); final calendars = json['calendars']; if (calendars is! Map) { - return const []; + return [ + for (final calendarId in calendarIds) + FreeBusyCalendarResultDto( + calendarId: calendarId, + status: FreeBusyEvaluationStatus.missing, + ), + ]; } + final responses = { + for (final entry in calendars.entries) + entry.key.toString().toLowerCase(): entry, + }; + return [ + for (final calendarId in calendarIds) + _googleFreeBusyResult(calendarId, responses[calendarId.toLowerCase()]), + ]; + } + + FreeBusyCalendarResultDto _googleFreeBusyResult( + String requestedCalendarId, + MapEntry? entry, + ) { + if (entry == null) { + return FreeBusyCalendarResultDto( + calendarId: requestedCalendarId, + status: FreeBusyEvaluationStatus.missing, + ); + } + final value = entry.value; + if (value is! Map) { + return FreeBusyCalendarResultDto( + calendarId: requestedCalendarId, + status: FreeBusyEvaluationStatus.failed, + errors: const ['Malformed free/busy result.'], + ); + } + final errors = [ + if (value['errors'] case final List values) + for (final error in values) + if (error is Map) + error['reason']?.toString() ?? + error['domain']?.toString() ?? + 'error' + else + error.toString(), + ]; + final busy = value['busy']; + if (busy is! List) { + return FreeBusyCalendarResultDto( + calendarId: requestedCalendarId, + status: FreeBusyEvaluationStatus.failed, + errors: errors.isEmpty ? const ['Malformed free/busy result.'] : errors, + ); + } + var malformedInterval = false; final slots = []; - for (final entry in calendars.entries) { - final value = entry.value; - if (value is! Map) { - continue; - } - final busy = value['busy']; - if (busy is! List) { - continue; - } - for (final item in busy.whereType()) { + for (final item in busy) { + if (item is Map) { final start = DateTime.tryParse(item['start']?.toString() ?? ''); final end = DateTime.tryParse(item['end']?.toString() ?? ''); if (start != null && end != null) { @@ -538,10 +598,20 @@ class GoogleCalendarApiClient end: end, ), ); + continue; } } + malformedInterval = true; } - return slots; + if (malformedInterval) errors.add('Malformed busy interval.'); + return FreeBusyCalendarResultDto( + calendarId: requestedCalendarId, + status: errors.isEmpty + ? FreeBusyEvaluationStatus.success + : FreeBusyEvaluationStatus.failed, + busySlots: slots, + errors: errors, + ); } Future _requestEmpty(String method, Uri uri) async { diff --git a/test/google_calendar/google_calendar_api_client_test.dart b/test/google_calendar/google_calendar_api_client_test.dart index 0a0dda2b..72503ab8 100644 --- a/test/google_calendar/google_calendar_api_client_test.dart +++ b/test/google_calendar/google_calendar_api_client_test.dart @@ -1,6 +1,7 @@ import 'dart:convert'; import 'package:busymax/src/calendar_providers/calendar_mutation.dart'; +import 'package:busymax/src/calendar_providers/calendar_sync_dto.dart'; import 'package:busymax/src/db/app_database.dart'; import 'package:busymax/src/features/sync/calendar_sync_engine.dart'; import 'package:busymax/src/google_calendar/google_calendar_api_client.dart'; @@ -241,6 +242,52 @@ void main() { }); }); + test('free/busy preserves success, failure, and missing calendars', () async { + final client = _client( + (_) => _json({ + 'calendars': { + 'free@example.com': {'busy': []}, + 'busy@example.com': { + 'busy': [ + {'start': '2026-09-14T17:00:00Z', 'end': '2026-09-14T18:00:00Z'}, + ], + }, + 'failed@example.com': { + 'errors': [ + {'domain': 'global', 'reason': 'notFound'}, + ], + 'busy': [], + }, + }, + }), + ); + + final results = await client.freeBusyDetails( + calendarIds: const [ + 'free@example.com', + 'busy@example.com', + 'failed@example.com', + 'missing@example.com', + ], + rangeStart: DateTime.utc(2026, 9, 14, 16), + rangeEnd: DateTime.utc(2026, 9, 14, 20), + ); + + expect(results.map((result) => result.calendarId), [ + 'free@example.com', + 'busy@example.com', + 'failed@example.com', + 'missing@example.com', + ]); + expect(results[0].status, FreeBusyEvaluationStatus.success); + expect(results[0].busySlots, isEmpty); + expect(results[1].status, FreeBusyEvaluationStatus.success); + expect(results[1].busySlots, hasLength(1)); + expect(results[2].status, FreeBusyEvaluationStatus.failed); + expect(results[2].errors, contains('notFound')); + expect(results[3].status, FreeBusyEvaluationStatus.missing); + }); + test( 'initial and token syncs request expanded recurring instances', () async { diff --git a/test/platform/android/android_adaptive_app_test.dart b/test/platform/android/android_adaptive_app_test.dart index 0980fad3..fa4c44ba 100644 --- a/test/platform/android/android_adaptive_app_test.dart +++ b/test/platform/android/android_adaptive_app_test.dart @@ -9,6 +9,7 @@ import 'package:busymax/src/android/presentation/android_schedule_screen.dart'; import 'package:busymax/src/android/presentation/android_settings_screen.dart'; import 'package:busymax/src/android/presentation/android_tasks_screen.dart'; import 'package:busymax/src/app/app_bootstrap.dart'; +import 'package:busymax/src/calendar_providers/cloud_calendar_client.dart'; import 'package:busymax/src/config/build_config.dart'; import 'package:busymax/src/db/app_database.dart'; import 'package:busymax/src/features/calendar/data/calendar_repository.dart'; @@ -18,6 +19,7 @@ import 'package:busymax/src/features/feedback/data/feedback_submission.dart'; import 'package:busymax/src/features/notifications/notification_reconciler.dart'; import 'package:busymax/src/features/recurrence/domain/recurrence_rule.dart'; import 'package:busymax/src/features/sync/pending_mutation_sync_requester.dart'; +import 'package:busymax/src/google_calendar/google_calendar_api_client.dart'; import 'package:busymax/src/features/task_lists/data/task_lists_repository.dart'; import 'package:busymax/src/features/tasks/data/tasks_repository.dart'; import 'package:busymax/src/features/tasks/domain/task_capabilities.dart'; @@ -29,10 +31,17 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; import 'package:package_info_plus/package_info_plus.dart'; import '../../support/memory_settings_store.dart'; +final _longScheduleDetailsNotes = List.generate( + 60, + (index) => 'Full task note line ${index + 1}', +).join('\n'); + void main() { TestWidgetsFlutterBinding.ensureInitialized(); @@ -293,6 +302,62 @@ void main() { expect(tester.takeException(), isNull); }); + testWidgets( + 'long schedule details remain scrollable across constrained viewports', + (tester) async { + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + addTearDown(tester.platformDispatcher.clearTextScaleFactorTestValue); + const viewports = [ + (Size(412, 915), 1.0, 'phone'), + (Size(700, 320), 1.0, 'short landscape'), + (Size(390, 844), 2.0, 'enlarged text'), + ]; + + for (final (size, textScale, label) in viewports) { + tester.view.physicalSize = size; + tester.platformDispatcher.textScaleFactorTestValue = textScale; + tester.binding.handleMetricsChanged(); + tester.binding.handleTextScaleFactorChanged(); + final harness = await _pumpApp( + tester, + AppSettings.defaults().copyWith( + androidScheduleViewMode: ScheduleViewMode.day, + ), + populated: true, + ); + try { + final more = find.ancestor( + of: find.textContaining('more').first, + matching: find.byType(TextButton), + ); + tester.widget(more).onPressed!(); + await tester.pumpAndSettle(); + final task = find.text('Date-only task'); + await _scrollUntilBuilt(tester, task); + await tester.ensureVisible(task); + await tester.tap(task); + await tester.pumpAndSettle(); + + final details = find.byKey( + const ValueKey('android-schedule-details-scroll'), + ); + final edit = find.text('Edit Task'); + expect(details, findsOneWidget, reason: label); + expect(find.text(_longScheduleDetailsNotes), findsOneWidget); + expect(edit, findsOneWidget, reason: label); + await tester.ensureVisible(edit); + await tester.pumpAndSettle(); + expect(edit.hitTestable(), findsOneWidget, reason: label); + expect(tester.takeException(), isNull, reason: label); + } finally { + await harness.dispose(); + } + } + }, + ); + testWidgets('Android invitation response queues the provider payload', ( tester, ) async { @@ -431,6 +496,91 @@ void main() { }, ); + testWidgets( + 'Google guest availability shows failures and missing results as unknown', + (tester) async { + final client = GoogleCalendarApiClient( + httpClient: MockClient( + (_) async => http.Response( + jsonEncode({ + 'calendars': { + 'free@example.com': {'busy': []}, + 'failed@example.com': { + 'errors': [ + {'domain': 'global', 'reason': 'notFound'}, + ], + 'busy': [], + }, + }, + }), + 200, + headers: {'Content-Type': 'application/json'}, + ), + ), + baseUri: Uri.parse('https://www.googleapis.com'), + authorizationHeaderProvider: () async => 'Bearer token', + ); + final harness = await _pumpApp( + tester, + AppSettings.defaults(), + calendarClient: client, + ); + addTearDown(harness.dispose); + final now = DateTime.now(); + const source = CalendarSourceEntity( + id: 'calendar', + accountId: 'google:editable', + provider: BusyProvider.google, + providerCalendarId: 'calendar', + summary: 'Editable calendar', + selected: true, + hidden: false, + readOnly: false, + isDeleted: false, + ); + tester + .state(find.byType(Navigator).first) + .push( + MaterialPageRoute( + builder: (_) => AndroidEventEditor( + sources: const [source], + draft: EventEditorDraft.existing( + eventId: 'availability-event', + accountId: source.accountId, + sourceId: source.id, + providerCalendarId: source.providerCalendarId, + title: 'Availability event', + allDay: false, + start: now, + end: now.add(const Duration(hours: 1)), + attendees: const [ + EventAttendeeDraft(email: 'free@example.com'), + EventAttendeeDraft(email: 'failed@example.com'), + EventAttendeeDraft(email: 'missing@example.com'), + ], + ), + ), + ), + ); + await tester.pumpAndSettle(); + final action = find.text('Check guest availability'); + await _scrollUntilBuilt(tester, action); + await tester.ensureVisible(action); + await tester.tap(action); + await tester.pumpAndSettle(); + + expect( + find.text('No busy periods reported for this interval'), + findsOneWidget, + ); + expect(find.text('Availability unknown'), findsNWidgets(2)); + expect(find.text('free@example.com'), findsOneWidget); + expect(find.text('failed@example.com'), findsOneWidget); + expect(find.text('missing@example.com'), findsOneWidget); + expect(tester.takeException(), isNull); + }, + ); + testWidgets('Android Settings exposes diagnostics and feedback routes', ( tester, ) async { @@ -787,15 +937,13 @@ void main() { final navigator = tester.state( find.byType(Navigator).first, ); - void open() => navigator.push( + void open(EventEditorDraft draft) => navigator.push( MaterialPageRoute( - builder: (_) => AndroidEventEditor( - sources: const [sourceA, sourceB], - draft: initial, - ), + builder: (_) => + AndroidEventEditor(sources: const [sourceA, sourceB], draft: draft), ), ); - open(); + open(initial); await tester.pumpAndSettle(); await tester.tap(find.byType(DropdownButtonFormField).first); @@ -810,13 +958,23 @@ void main() { expect(saved['accountId'], sourceB.accountId); expect(saved['sourceId'], sourceB.id); expect(saved['providerCalendarId'], sourceB.providerCalendarId); + expect(saved['v'], 3); + expect(saved['eventId'], isNull); + expect(saved['start'], initial.start!.toIso8601String()); final route = ModalRoute.of( tester.element(find.byType(AndroidEventEditor)), )!; navigator.removeRoute(route); await tester.pumpAndSettle(); - open(); + final reopenedOnAnotherDate = EventEditorDraft.newEvent( + accountId: sourceA.accountId, + sourceId: sourceA.id, + providerCalendarId: sourceA.providerCalendarId, + start: now.add(const Duration(days: 3)), + end: now.add(const Duration(days: 3, hours: 1)), + ); + open(reopenedOnAnotherDate); await tester.pumpAndSettle(); await tester.tap(find.text('Restore')); await tester.pumpAndSettle(); @@ -828,6 +986,7 @@ void main() { .cast(); expect(rewritten['showAs'], isNull); expect(rewritten['visibility'], isNull); + expect(rewritten['start'], initial.start!.toIso8601String()); expect(tester.takeException(), isNull); }); @@ -1303,6 +1462,7 @@ Future<_AndroidAppHarness> _pumpApp( Future Function(AppDatabase database)? seedDatabase, Future Function(String accountId)? onCalendarSync, FeedbackSubmissionService? feedbackService, + CloudCalendarClient? calendarClient, }) async { final database = AppDatabase.memoryForTests(); if (seedDatabase != null) await seedDatabase(database); @@ -1340,6 +1500,10 @@ Future<_AndroidAppHarness> _pumpApp( }), if (feedbackService != null) feedbackSubmissionServiceProvider.overrideWithValue(feedbackService), + if (calendarClient != null) + calendarRemoteApiClientForAccountProvider.overrideWith( + (ref, accountId) => calendarClient, + ), initialAppSettingsProvider.overrideWithValue(settings), localSettingsStoreProvider.overrideWithValue(MemorySettingsStore()), allAccountsSyncRunnerProvider.overrideWithValue(() async {}), @@ -1412,6 +1576,7 @@ Future _seedPopulatedSchedule(AppDatabase database) async { taskListId: 'visible-list', id: 'date-only-task', title: 'Date-only task', + notes: Value(_longScheduleDetailsNotes), dueUtc: Value( '${today.year.toString().padLeft(4, '0')}-' '${today.month.toString().padLeft(2, '0')}-' diff --git a/test/platform/android/android_notification_policy_test.dart b/test/platform/android/android_notification_policy_test.dart index 080b8e58..82f492a1 100644 --- a/test/platform/android/android_notification_policy_test.dart +++ b/test/platform/android/android_notification_policy_test.dart @@ -90,25 +90,44 @@ void main() { ); }); - test('registration fingerprint changes for privacy and alarm precision', () { - final normal = AppSettings.defaults(); - final private = normal.copyWith( - notificationDetailLevel: NotificationDetailLevel.private, - ); - - expect( - androidNotificationRegistrationState(settings: normal, exact: false), - isNot( - androidNotificationRegistrationState(settings: private, exact: false), - ), - ); - expect( - androidNotificationRegistrationState(settings: normal, exact: false), - isNot( - androidNotificationRegistrationState(settings: normal, exact: true), - ), - ); - }); + test( + 'registration fingerprint changes for privacy, precision, and content', + () { + final normal = AppSettings.defaults(); + final private = normal.copyWith( + notificationDetailLevel: NotificationDetailLevel.private, + ); + + expect( + androidNotificationRegistrationState(settings: normal, exact: false), + isNot( + androidNotificationRegistrationState(settings: private, exact: false), + ), + ); + expect( + androidNotificationRegistrationState(settings: normal, exact: false), + isNot( + androidNotificationRegistrationState(settings: normal, exact: true), + ), + ); + expect( + androidNotificationRegistrationState( + settings: normal, + exact: false, + title: 'Original title', + body: 'Original body', + ), + isNot( + androidNotificationRegistrationState( + settings: normal, + exact: false, + title: 'Renamed title', + body: 'Original body', + ), + ), + ); + }, + ); test('explicit Open action launches the user interface', () { final action = androidOpenNotificationAction( diff --git a/test/platform/android/android_notification_reconciliation_test.dart b/test/platform/android/android_notification_reconciliation_test.dart index 7e1bcd4d..0ef4709a 100644 --- a/test/platform/android/android_notification_reconciliation_test.dart +++ b/test/platform/android/android_notification_reconciliation_test.dart @@ -159,6 +159,7 @@ void main() { final state = androidNotificationRegistrationState( settings: settings, exact: false, + title: 'Visible meeting', ); await _insertMapping( database, @@ -249,6 +250,53 @@ void main() { expect(plugin.scheduledIds, isNot(contains(platformId))); }); + test('content-only edits replace an unchanged pending reminder', () async { + final scheduledAt = DateTime.now().add(const Duration(hours: 1)); + await _insertReminder( + database, + generation: 'generation-1', + scheduledAt: scheduledAt, + ); + final notifications = service(); + await notifications.initialize(timeZoneId: 'America/Vancouver'); + await notifications.reconcile(); + final mapping = await database + .select(database.androidNotificationMappings) + .getSingle(); + final platformId = mapping.platformId; + plugin.cancelledIds.clear(); + plugin.scheduled.clear(); + + await (database.update( + database.notificationSchedule, + )..where((table) => table.id.equals('reminder-1'))).write( + const NotificationScheduleCompanion( + title: Value('Renamed meeting'), + body: Value('Updated location'), + ), + ); + await notifications.reconcile(); + + expect(plugin.cancelledIds, contains(platformId)); + expect(plugin.scheduledIds, contains(platformId)); + final call = plugin.scheduled.single; + expect(call.title, 'Renamed meeting'); + expect(call.body, 'Updated location'); + final updatedMapping = await database + .select(database.androidNotificationMappings) + .getSingle(); + expect(updatedMapping.generation, 'generation-1'); + expect( + updatedMapping.state, + androidNotificationRegistrationState( + settings: settings, + exact: false, + title: 'Renamed meeting', + body: 'Updated location', + ), + ); + }); + test( 'overdue pending summary is replaced after privacy and precision change', () async { @@ -299,7 +347,11 @@ void main() { .getSingle(); expect( row.state, - androidNotificationRegistrationState(settings: settings, exact: true), + androidNotificationRegistrationState( + settings: settings, + exact: true, + title: const AndroidNotificationStrings().dueTodayTitle, + ), ); }, ); @@ -393,7 +445,8 @@ class _StatefulNotificationPlugin implements AndroidNotificationBackend { final Set pendingIds = {}; final Set activeIds = {}; final List cancelledIds = []; - final List<({int id, String? body, AndroidScheduleMode mode})> scheduled = []; + final List<({int id, String? title, String? body, AndroidScheduleMode mode})> + scheduled = []; bool activeQueryFails = false; Iterable get scheduledIds => scheduled.map((value) => value.id); @@ -442,7 +495,12 @@ class _StatefulNotificationPlugin implements AndroidNotificationBackend { String? payload, DateTimeComponents? matchDateTimeComponents, }) async { - scheduled.add((id: id, body: body, mode: androidScheduleMode)); + scheduled.add(( + id: id, + title: title, + body: body, + mode: androidScheduleMode, + )); pendingIds.add(id); } From 47064f2617a57677ce513677d5d7b612f43a8fce Mon Sep 17 00:00:00 2001 From: ChenZhenIO Date: Mon, 14 Sep 2026 20:39:07 -0700 Subject: [PATCH 07/25] Update Android verification documentation with new baseline and corrected values --- docs/android_verification.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/android_verification.md b/docs/android_verification.md index 78e5042a..83c8f6e3 100644 --- a/docs/android_verification.md +++ b/docs/android_verification.md @@ -11,12 +11,12 @@ test is never counted as a pass. | Item | Recorded value | |---|---| -| Source baseline | `b0f816ca4687decd4b502d0f0fdf6ac518de40cc` | -| Reviewed Android foundation | The source baseline plus the uncommitted `BusyMax_Android_a8d0604_Review.md` corrections described here | +| Source baseline | `f14f309f0357712f878b53df7b2643105996684a` | +| Reviewed Android foundation | The source baseline, including the `BusyMax_Android_a8d0604_Review.md` corrections and the four follow-up correctness fixes described here | | Verification host | `skynet3`, Linux x86_64, Ubuntu kernel `7.0.0-31-generic` | | Verification time | Current correction pass on `2026-09-14` (`America/Vancouver`) | | Flutter / Dart | Flutter `3.47.2` (`d3b14c8769`), bundled Dart `3.13.2` | -| Java | Eclipse Temurin JDK `17.0.18+8` | +| Java | Eclipse Temurin JDK `17.0.20.1+1`, verified temporary archive used for the current release build | | Android toolchain | platform `37.0` revision 2, Build Tools `37.0.0`, Gradle `9.4.1`, AGP `9.2.0` | | Kotlin configuration | built-in Kotlin enabled; plugin declaration `2.4.0`; `android.newDsl=false` for the documented Flutter 3.47.2 compatibility requirement | | Application | `io.busystack.busymax`, beta `0.2.2+2`, min SDK 24, target/compile SDK 37 | @@ -65,8 +65,8 @@ uploaded. | Artifact | Size | SHA-256 | |---|---:|---| -| `build/app/outputs/flutter-apk/app-release.apk` | 86,948,944 bytes | `9e7a0ee03242e5d206e3c5df724b4bfcf999342aa15e3853275c72e5558545f5` | -| `build/app/outputs/bundle/release/app-release.aab` | 82,776,685 bytes | `e48f2e64db3bb0186fb898ae26a36e40746efe9cc1e2645a26a9b49ae577848a` | +| `build/app/outputs/flutter-apk/app-release.apk` | 86,965,328 bytes | `3ba8ab41fc7d23bad2d44c1d43a078cef7893b42fdb6693e7347d0081eaa7b12` | +| `build/app/outputs/bundle/release/app-release.aab` | 82,785,235 bytes | `139f69721718bd9bb2a0a603e354eb10a22a4dcfe5afbd3ce5d546d29971ef01` | Inspection found `arm64-v8a`, `armeabi-v7a`, and `x86_64`, with Flutter, application, Dart JNI, and SQLite native libraries in each ABI. Every packaged From f5033307fbe9ff413b1bc6bfc5497e771e544018 Mon Sep 17 00:00:00 2001 From: ChenZhenIO Date: Tue, 15 Sep 2026 13:13:40 -0700 Subject: [PATCH 08/25] Implemented filterable Schedule search across Linux, Windows, and Android --- lib/l10n/app_ar.arb | 18 +- lib/l10n/app_de.arb | 18 +- lib/l10n/app_en.arb | 18 +- lib/l10n/app_es.arb | 18 +- lib/l10n/app_et.arb | 18 +- lib/l10n/app_fa.arb | 18 +- lib/l10n/app_fi.arb | 18 +- lib/l10n/app_fr.arb | 18 +- lib/l10n/app_hi.arb | 18 +- lib/l10n/app_it.arb | 18 +- lib/l10n/app_ja.arb | 18 +- lib/l10n/app_ko.arb | 18 +- lib/l10n/app_pl.arb | 18 +- lib/l10n/app_pt_PT.arb | 18 +- lib/l10n/app_ru.arb | 18 +- lib/l10n/app_vi.arb | 18 +- lib/l10n/app_zh.arb | 18 +- lib/l10n/app_zh_Hans.arb | 18 +- lib/l10n/app_zh_Hant.arb | 18 +- lib/l10n/generated/app_localizations.dart | 96 +++ lib/l10n/generated/app_localizations_ar.dart | 48 ++ lib/l10n/generated/app_localizations_de.dart | 48 ++ lib/l10n/generated/app_localizations_en.dart | 48 ++ lib/l10n/generated/app_localizations_es.dart | 48 ++ lib/l10n/generated/app_localizations_et.dart | 48 ++ lib/l10n/generated/app_localizations_fa.dart | 48 ++ lib/l10n/generated/app_localizations_fi.dart | 48 ++ lib/l10n/generated/app_localizations_fr.dart | 48 ++ lib/l10n/generated/app_localizations_hi.dart | 48 ++ lib/l10n/generated/app_localizations_it.dart | 48 ++ lib/l10n/generated/app_localizations_ja.dart | 48 ++ lib/l10n/generated/app_localizations_ko.dart | 48 ++ lib/l10n/generated/app_localizations_pl.dart | 48 ++ lib/l10n/generated/app_localizations_pt.dart | 96 +++ lib/l10n/generated/app_localizations_ru.dart | 48 ++ lib/l10n/generated/app_localizations_vi.dart | 48 ++ lib/l10n/generated/app_localizations_zh.dart | 144 +++++ .../presentation/android_schedule_screen.dart | 302 ++++++--- .../android_schedule_search_filters.dart | 259 ++++++++ .../presentation/android_tasks_screen.dart | 4 +- .../presentation/schedule_agenda_view.dart | 33 +- .../presentation/schedule_search_filters.dart | 255 ++++++++ .../presentation/schedule_search_labels.dart | 28 + .../schedule_search_result_text.dart | 40 ++ .../presentation/schedule_workspace.dart | 308 +++++++--- .../tray/data/tray_presentation_service.dart | 2 +- .../platform/linux_header_bar_service.dart | 26 + lib/src/schedule/schedule_filters.dart | 24 +- lib/src/schedule/schedule_item.dart | 4 + lib/src/schedule/schedule_repository.dart | 113 ++-- .../schedule/schedule_search_criteria.dart | 139 +++++ lib/src/schedule/schedule_search_match.dart | 209 +++++++ lib/src/ui/windows/windows_schedule_page.dart | 576 +++++++++++------- .../windows/windows_schedule_search_pane.dart | 269 ++++++++ lib/src/ui/windows/windows_tasks_page.dart | 4 +- linux/runner/my_application.cc | 3 + .../schedule_search_platform_test.dart | 504 +++++++++++++++ .../presentation/schedule_views_test.dart | 44 +- .../schedule_workspace_states_test.dart | 56 +- .../schedule/schedule_search_test.dart | 436 ++++++++++++- .../linux_header_bar_service_test.dart | 7 +- 61 files changed, 4561 insertions(+), 482 deletions(-) create mode 100644 lib/src/android/presentation/android_schedule_search_filters.dart create mode 100644 lib/src/features/schedule/presentation/schedule_search_filters.dart create mode 100644 lib/src/features/schedule/presentation/schedule_search_labels.dart create mode 100644 lib/src/features/schedule/presentation/schedule_search_result_text.dart create mode 100644 lib/src/schedule/schedule_search_criteria.dart create mode 100644 lib/src/schedule/schedule_search_match.dart create mode 100644 lib/src/ui/windows/windows_schedule_search_pane.dart create mode 100644 test/features/schedule/presentation/schedule_search_platform_test.dart diff --git a/lib/l10n/app_ar.arb b/lib/l10n/app_ar.arb index d9aec457..55f0355d 100644 --- a/lib/l10n/app_ar.arb +++ b/lib/l10n/app_ar.arb @@ -1025,5 +1025,21 @@ "position": {"type": "String"}, "days": {"type": "String"} } - } + }, + "searchFilters": "عوامل تصفية البحث", + "searchType": "النوع", + "searchDate": "التاريخ", + "searchAnyDate": "أي تاريخ", + "searchThisWeek": "هذا الأسبوع", + "searchCustomRange": "نطاق مخصص", + "searchTaskStatus": "حالة المهمة", + "searchTaskDue": "استحقاق المهمة", + "searchAnyDueState": "أي حالة استحقاق", + "searchNoDueDate": "بلا تاريخ استحقاق", + "searchPerson": "الشخص", + "searchSources": "المصادر", + "searchClearFilters": "إعادة ضبط عوامل التصفية", + "searchFiltersAction": "عوامل التصفية", + "searchNoSources": "لم يتم تحديد مصادر للبحث", + "searchClearText": "مسح النص" } diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 5d4a5fb9..745fbf25 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -1088,5 +1088,21 @@ "position": {"type": "String"}, "days": {"type": "String"} } - } + }, + "searchFilters": "Suchfilter", + "searchType": "Eintragstyp", + "searchDate": "Datum", + "searchAnyDate": "Beliebiges Datum", + "searchThisWeek": "Diese Woche", + "searchCustomRange": "Eigener Zeitraum", + "searchTaskStatus": "Aufgabenstatus", + "searchTaskDue": "Aufgabenfälligkeit", + "searchAnyDueState": "Beliebige Fälligkeit", + "searchNoDueDate": "Kein Fälligkeitsdatum", + "searchPerson": "Beteiligte Person", + "searchSources": "Quellen", + "searchClearFilters": "Filter zurücksetzen", + "searchFiltersAction": "Suchfilter öffnen", + "searchNoSources": "Keine Suchquellen ausgewählt", + "searchClearText": "Text löschen" } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 00cc5f45..db18c680 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1564,5 +1564,21 @@ "position": {"type": "String"}, "days": {"type": "String"} } - } + }, + "searchFilters": "Search filters", + "searchType": "Type", + "searchDate": "Date", + "searchAnyDate": "Any date", + "searchThisWeek": "This week", + "searchCustomRange": "Custom range", + "searchTaskStatus": "Task status", + "searchTaskDue": "Task due", + "searchAnyDueState": "Any due state", + "searchNoDueDate": "No due date", + "searchPerson": "Person", + "searchSources": "Sources", + "searchClearFilters": "Clear filters", + "searchFiltersAction": "Filters", + "searchNoSources": "No search sources selected", + "searchClearText": "Clear text" } diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 9d57c373..20cd0251 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -1088,5 +1088,21 @@ "position": {"type": "String"}, "days": {"type": "String"} } - } + }, + "searchFilters": "Filtros de búsqueda", + "searchType": "Tipo", + "searchDate": "Fecha", + "searchAnyDate": "Cualquier fecha", + "searchThisWeek": "Esta semana", + "searchCustomRange": "Intervalo personalizado", + "searchTaskStatus": "Estado de tareas", + "searchTaskDue": "Vencimiento de tareas", + "searchAnyDueState": "Cualquier vencimiento", + "searchNoDueDate": "Sin fecha de vencimiento", + "searchPerson": "Persona", + "searchSources": "Fuentes", + "searchClearFilters": "Restablecer filtros", + "searchFiltersAction": "Filtros", + "searchNoSources": "No hay fuentes de búsqueda seleccionadas", + "searchClearText": "Borrar texto" } diff --git a/lib/l10n/app_et.arb b/lib/l10n/app_et.arb index 17ca8c45..65daff73 100644 --- a/lib/l10n/app_et.arb +++ b/lib/l10n/app_et.arb @@ -1203,5 +1203,21 @@ "position": {"type": "String"}, "days": {"type": "String"} } - } + }, + "searchFilters": "Otsingufiltrid", + "searchType": "Tüüp", + "searchDate": "Kuupäev", + "searchAnyDate": "Kõik kuupäevad", + "searchThisWeek": "See nädal", + "searchCustomRange": "Kohandatud vahemik", + "searchTaskStatus": "Ülesande olek", + "searchTaskDue": "Ülesande tähtaeg", + "searchAnyDueState": "Kõik tähtajad", + "searchNoDueDate": "Tähtajata", + "searchPerson": "Isik", + "searchSources": "Allikad", + "searchClearFilters": "Lähtesta filtrid", + "searchFiltersAction": "Filtrid", + "searchNoSources": "Otsinguallikaid pole valitud", + "searchClearText": "Tühjenda tekst" } diff --git a/lib/l10n/app_fa.arb b/lib/l10n/app_fa.arb index 6337b32b..dbb7c1e2 100644 --- a/lib/l10n/app_fa.arb +++ b/lib/l10n/app_fa.arb @@ -1099,5 +1099,21 @@ "position": {"type": "String"}, "days": {"type": "String"} } - } + }, + "searchFilters": "فیلترهای جستجو", + "searchType": "نوع", + "searchDate": "تاریخ", + "searchAnyDate": "هر تاریخی", + "searchThisWeek": "این هفته", + "searchCustomRange": "بازهٔ دلخواه", + "searchTaskStatus": "وضعیت کار", + "searchTaskDue": "سررسید کار", + "searchAnyDueState": "هر وضعیت سررسید", + "searchNoDueDate": "بدون تاریخ سررسید", + "searchPerson": "شخص", + "searchSources": "منابع", + "searchClearFilters": "بازنشانی فیلترها", + "searchFiltersAction": "فیلترها", + "searchNoSources": "هیچ منبع جستجویی انتخاب نشده است", + "searchClearText": "پاک کردن متن" } diff --git a/lib/l10n/app_fi.arb b/lib/l10n/app_fi.arb index e8055798..cf7422fd 100644 --- a/lib/l10n/app_fi.arb +++ b/lib/l10n/app_fi.arb @@ -1025,5 +1025,21 @@ "position": {"type": "String"}, "days": {"type": "String"} } - } + }, + "searchFilters": "Hakusuodattimet", + "searchType": "Tyyppi", + "searchDate": "Päivämäärä", + "searchAnyDate": "Kaikki päivämäärät", + "searchThisWeek": "Tämä viikko", + "searchCustomRange": "Mukautettu aikaväli", + "searchTaskStatus": "Tehtävän tila", + "searchTaskDue": "Tehtävän erääntyminen", + "searchAnyDueState": "Kaikki erääntymistilat", + "searchNoDueDate": "Ei eräpäivää", + "searchPerson": "Henkilö", + "searchSources": "Lähteet", + "searchClearFilters": "Nollaa suodattimet", + "searchFiltersAction": "Suodattimet", + "searchNoSources": "Hakulähteitä ei ole valittu", + "searchClearText": "Tyhjennä teksti" } diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 23d15a7f..404d69a1 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -1088,5 +1088,21 @@ "position": {"type": "String"}, "days": {"type": "String"} } - } + }, + "searchFilters": "Filtres de recherche", + "searchType": "Type d’élément", + "searchDate": "Date de recherche", + "searchAnyDate": "Toutes les dates", + "searchThisWeek": "Cette semaine", + "searchCustomRange": "Période personnalisée", + "searchTaskStatus": "État des tâches", + "searchTaskDue": "Échéance des tâches", + "searchAnyDueState": "Toute échéance", + "searchNoDueDate": "Sans échéance", + "searchPerson": "Personne", + "searchSources": "Sources de recherche", + "searchClearFilters": "Réinitialiser les filtres", + "searchFiltersAction": "Filtres", + "searchNoSources": "Aucune source de recherche sélectionnée", + "searchClearText": "Effacer le texte" } diff --git a/lib/l10n/app_hi.arb b/lib/l10n/app_hi.arb index 3b3455fe..38d0a80f 100644 --- a/lib/l10n/app_hi.arb +++ b/lib/l10n/app_hi.arb @@ -1025,5 +1025,21 @@ "position": {"type": "String"}, "days": {"type": "String"} } - } + }, + "searchFilters": "खोज फ़िल्टर", + "searchType": "प्रकार", + "searchDate": "तारीख", + "searchAnyDate": "कोई भी तारीख", + "searchThisWeek": "इस सप्ताह", + "searchCustomRange": "कस्टम अवधि", + "searchTaskStatus": "कार्य स्थिति", + "searchTaskDue": "कार्य की नियत तारीख", + "searchAnyDueState": "कोई भी नियत स्थिति", + "searchNoDueDate": "बिना नियत तारीख", + "searchPerson": "व्यक्ति", + "searchSources": "स्रोत", + "searchClearFilters": "फ़िल्टर रीसेट करें", + "searchFiltersAction": "फ़िल्टर", + "searchNoSources": "कोई खोज स्रोत नहीं चुना गया", + "searchClearText": "पाठ साफ़ करें" } diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb index e4e6f274..458802e4 100644 --- a/lib/l10n/app_it.arb +++ b/lib/l10n/app_it.arb @@ -1025,5 +1025,21 @@ "position": {"type": "String"}, "days": {"type": "String"} } - } + }, + "searchFilters": "Filtri di ricerca", + "searchType": "Tipo", + "searchDate": "Data", + "searchAnyDate": "Qualsiasi data", + "searchThisWeek": "Questa settimana", + "searchCustomRange": "Intervallo personalizzato", + "searchTaskStatus": "Stato attività", + "searchTaskDue": "Scadenza attività", + "searchAnyDueState": "Qualsiasi scadenza", + "searchNoDueDate": "Senza scadenza", + "searchPerson": "Persona", + "searchSources": "Origini", + "searchClearFilters": "Reimposta filtri", + "searchFiltersAction": "Filtri", + "searchNoSources": "Nessuna origine di ricerca selezionata", + "searchClearText": "Cancella testo" } diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index 13c78649..618f3cd4 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -1025,5 +1025,21 @@ "position": {"type": "String"}, "days": {"type": "String"} } - } + }, + "searchFilters": "検索フィルター", + "searchType": "種類", + "searchDate": "日付", + "searchAnyDate": "すべての日付", + "searchThisWeek": "今週", + "searchCustomRange": "期間を指定", + "searchTaskStatus": "タスクの状態", + "searchTaskDue": "タスクの期限", + "searchAnyDueState": "すべての期限状態", + "searchNoDueDate": "期限なし", + "searchPerson": "人物", + "searchSources": "検索元", + "searchClearFilters": "フィルターをリセット", + "searchFiltersAction": "フィルター", + "searchNoSources": "検索元が選択されていません", + "searchClearText": "テキストを消去" } diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index cbc1e9c9..c3b9bd98 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -1025,5 +1025,21 @@ "position": {"type": "String"}, "days": {"type": "String"} } - } + }, + "searchFilters": "검색 필터", + "searchType": "유형", + "searchDate": "날짜", + "searchAnyDate": "모든 날짜", + "searchThisWeek": "이번 주", + "searchCustomRange": "사용자 지정 기간", + "searchTaskStatus": "작업 상태", + "searchTaskDue": "작업 기한", + "searchAnyDueState": "모든 기한 상태", + "searchNoDueDate": "기한 없음", + "searchPerson": "사람", + "searchSources": "검색 소스", + "searchClearFilters": "필터 초기화", + "searchFiltersAction": "필터", + "searchNoSources": "선택한 검색 소스가 없습니다", + "searchClearText": "텍스트 지우기" } diff --git a/lib/l10n/app_pl.arb b/lib/l10n/app_pl.arb index 0ac60c43..dd167dcf 100644 --- a/lib/l10n/app_pl.arb +++ b/lib/l10n/app_pl.arb @@ -1735,5 +1735,21 @@ "type": "String" } } - } + }, + "searchFilters": "Filtry wyszukiwania", + "searchType": "Typ", + "searchDate": "Data", + "searchAnyDate": "Dowolna data", + "searchThisWeek": "Ten tydzień", + "searchCustomRange": "Zakres niestandardowy", + "searchTaskStatus": "Stan zadania", + "searchTaskDue": "Termin zadania", + "searchAnyDueState": "Dowolny stan terminu", + "searchNoDueDate": "Bez terminu", + "searchPerson": "Osoba", + "searchSources": "Źródła", + "searchClearFilters": "Resetuj filtry", + "searchFiltersAction": "Filtry", + "searchNoSources": "Nie wybrano źródeł wyszukiwania", + "searchClearText": "Wyczyść tekst" } diff --git a/lib/l10n/app_pt_PT.arb b/lib/l10n/app_pt_PT.arb index 4ca981a3..e7fbece0 100644 --- a/lib/l10n/app_pt_PT.arb +++ b/lib/l10n/app_pt_PT.arb @@ -1025,5 +1025,21 @@ "position": {"type": "String"}, "days": {"type": "String"} } - } + }, + "searchFilters": "Filtros de pesquisa", + "searchType": "Tipo", + "searchDate": "Data", + "searchAnyDate": "Qualquer data", + "searchThisWeek": "Esta semana", + "searchCustomRange": "Intervalo personalizado", + "searchTaskStatus": "Estado das tarefas", + "searchTaskDue": "Prazo das tarefas", + "searchAnyDueState": "Qualquer prazo", + "searchNoDueDate": "Sem data de conclusão", + "searchPerson": "Pessoa", + "searchSources": "Origens", + "searchClearFilters": "Repor filtros", + "searchFiltersAction": "Filtros", + "searchNoSources": "Nenhuma origem de pesquisa selecionada", + "searchClearText": "Limpar texto" } diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index ac20f242..4fe8a359 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -1025,5 +1025,21 @@ "position": {"type": "String"}, "days": {"type": "String"} } - } + }, + "searchFilters": "Фильтры поиска", + "searchType": "Тип", + "searchDate": "Дата", + "searchAnyDate": "Любая дата", + "searchThisWeek": "Эта неделя", + "searchCustomRange": "Свой диапазон", + "searchTaskStatus": "Статус задач", + "searchTaskDue": "Срок задач", + "searchAnyDueState": "Любой срок", + "searchNoDueDate": "Без срока", + "searchPerson": "Участник", + "searchSources": "Источники", + "searchClearFilters": "Сбросить фильтры", + "searchFiltersAction": "Фильтры", + "searchNoSources": "Источники поиска не выбраны", + "searchClearText": "Очистить текст" } diff --git a/lib/l10n/app_vi.arb b/lib/l10n/app_vi.arb index 469eb74a..29ae732f 100644 --- a/lib/l10n/app_vi.arb +++ b/lib/l10n/app_vi.arb @@ -1025,5 +1025,21 @@ "position": {"type": "String"}, "days": {"type": "String"} } - } + }, + "searchFilters": "Bộ lọc tìm kiếm", + "searchType": "Loại", + "searchDate": "Ngày", + "searchAnyDate": "Mọi ngày", + "searchThisWeek": "Tuần này", + "searchCustomRange": "Khoảng tùy chọn", + "searchTaskStatus": "Trạng thái công việc", + "searchTaskDue": "Hạn công việc", + "searchAnyDueState": "Mọi trạng thái hạn", + "searchNoDueDate": "Không có hạn", + "searchPerson": "Người", + "searchSources": "Nguồn", + "searchClearFilters": "Đặt lại bộ lọc", + "searchFiltersAction": "Bộ lọc", + "searchNoSources": "Chưa chọn nguồn tìm kiếm", + "searchClearText": "Xóa văn bản" } diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 54553c10..a9ecfd74 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -1025,5 +1025,21 @@ "position": {"type": "String"}, "days": {"type": "String"} } - } + }, + "searchFilters": "搜索筛选条件", + "searchType": "类型", + "searchDate": "日期", + "searchAnyDate": "任何日期", + "searchThisWeek": "本周", + "searchCustomRange": "自定义范围", + "searchTaskStatus": "任务状态", + "searchTaskDue": "任务到期状态", + "searchAnyDueState": "任何到期状态", + "searchNoDueDate": "无截止日期", + "searchPerson": "人员", + "searchSources": "来源", + "searchClearFilters": "重置筛选条件", + "searchFiltersAction": "筛选", + "searchNoSources": "未选择搜索来源", + "searchClearText": "清除文字" } diff --git a/lib/l10n/app_zh_Hans.arb b/lib/l10n/app_zh_Hans.arb index a9fb3b4a..8c7debf8 100644 --- a/lib/l10n/app_zh_Hans.arb +++ b/lib/l10n/app_zh_Hans.arb @@ -1025,5 +1025,21 @@ "position": {"type": "String"}, "days": {"type": "String"} } - } + }, + "searchFilters": "搜索筛选条件", + "searchType": "类型", + "searchDate": "日期", + "searchAnyDate": "任何日期", + "searchThisWeek": "本周", + "searchCustomRange": "自定义范围", + "searchTaskStatus": "任务状态", + "searchTaskDue": "任务到期状态", + "searchAnyDueState": "任何到期状态", + "searchNoDueDate": "无截止日期", + "searchPerson": "人员", + "searchSources": "来源", + "searchClearFilters": "重置筛选条件", + "searchFiltersAction": "筛选", + "searchNoSources": "未选择搜索来源", + "searchClearText": "清除文字" } diff --git a/lib/l10n/app_zh_Hant.arb b/lib/l10n/app_zh_Hant.arb index 04f31556..573a4791 100644 --- a/lib/l10n/app_zh_Hant.arb +++ b/lib/l10n/app_zh_Hant.arb @@ -1025,5 +1025,21 @@ "position": {"type": "String"}, "days": {"type": "String"} } - } + }, + "searchFilters": "搜尋篩選條件", + "searchType": "類型", + "searchDate": "日期", + "searchAnyDate": "任何日期", + "searchThisWeek": "本週", + "searchCustomRange": "自訂範圍", + "searchTaskStatus": "工作狀態", + "searchTaskDue": "工作到期狀態", + "searchAnyDueState": "任何到期狀態", + "searchNoDueDate": "無截止日期", + "searchPerson": "人員", + "searchSources": "來源", + "searchClearFilters": "重設篩選條件", + "searchFiltersAction": "篩選", + "searchNoSources": "未選取搜尋來源", + "searchClearText": "清除文字" } diff --git a/lib/l10n/generated/app_localizations.dart b/lib/l10n/generated/app_localizations.dart index ff8247f1..c404d232 100644 --- a/lib/l10n/generated/app_localizations.dart +++ b/lib/l10n/generated/app_localizations.dart @@ -4867,6 +4867,102 @@ abstract class AppLocalizations { String position, String days, ); + + /// No description provided for @searchFilters. + /// + /// In en, this message translates to: + /// **'Search filters'** + String get searchFilters; + + /// No description provided for @searchType. + /// + /// In en, this message translates to: + /// **'Type'** + String get searchType; + + /// No description provided for @searchDate. + /// + /// In en, this message translates to: + /// **'Date'** + String get searchDate; + + /// No description provided for @searchAnyDate. + /// + /// In en, this message translates to: + /// **'Any date'** + String get searchAnyDate; + + /// No description provided for @searchThisWeek. + /// + /// In en, this message translates to: + /// **'This week'** + String get searchThisWeek; + + /// No description provided for @searchCustomRange. + /// + /// In en, this message translates to: + /// **'Custom range'** + String get searchCustomRange; + + /// No description provided for @searchTaskStatus. + /// + /// In en, this message translates to: + /// **'Task status'** + String get searchTaskStatus; + + /// No description provided for @searchTaskDue. + /// + /// In en, this message translates to: + /// **'Task due'** + String get searchTaskDue; + + /// No description provided for @searchAnyDueState. + /// + /// In en, this message translates to: + /// **'Any due state'** + String get searchAnyDueState; + + /// No description provided for @searchNoDueDate. + /// + /// In en, this message translates to: + /// **'No due date'** + String get searchNoDueDate; + + /// No description provided for @searchPerson. + /// + /// In en, this message translates to: + /// **'Person'** + String get searchPerson; + + /// No description provided for @searchSources. + /// + /// In en, this message translates to: + /// **'Sources'** + String get searchSources; + + /// No description provided for @searchClearFilters. + /// + /// In en, this message translates to: + /// **'Clear filters'** + String get searchClearFilters; + + /// No description provided for @searchFiltersAction. + /// + /// In en, this message translates to: + /// **'Filters'** + String get searchFiltersAction; + + /// No description provided for @searchNoSources. + /// + /// In en, this message translates to: + /// **'No search sources selected'** + String get searchNoSources; + + /// No description provided for @searchClearText. + /// + /// In en, this message translates to: + /// **'Clear text'** + String get searchClearText; } class _AppLocalizationsDelegate diff --git a/lib/l10n/generated/app_localizations_ar.dart b/lib/l10n/generated/app_localizations_ar.dart index 939d7938..1f902a87 100644 --- a/lib/l10n/generated/app_localizations_ar.dart +++ b/lib/l10n/generated/app_localizations_ar.dart @@ -2989,4 +2989,52 @@ class AppLocalizationsAr extends AppLocalizations { }); return '$frequency $_temp0'; } + + @override + String get searchFilters => 'عوامل تصفية البحث'; + + @override + String get searchType => 'النوع'; + + @override + String get searchDate => 'التاريخ'; + + @override + String get searchAnyDate => 'أي تاريخ'; + + @override + String get searchThisWeek => 'هذا الأسبوع'; + + @override + String get searchCustomRange => 'نطاق مخصص'; + + @override + String get searchTaskStatus => 'حالة المهمة'; + + @override + String get searchTaskDue => 'استحقاق المهمة'; + + @override + String get searchAnyDueState => 'أي حالة استحقاق'; + + @override + String get searchNoDueDate => 'بلا تاريخ استحقاق'; + + @override + String get searchPerson => 'الشخص'; + + @override + String get searchSources => 'المصادر'; + + @override + String get searchClearFilters => 'إعادة ضبط عوامل التصفية'; + + @override + String get searchFiltersAction => 'عوامل التصفية'; + + @override + String get searchNoSources => 'لم يتم تحديد مصادر للبحث'; + + @override + String get searchClearText => 'مسح النص'; } diff --git a/lib/l10n/generated/app_localizations_de.dart b/lib/l10n/generated/app_localizations_de.dart index a96c9d0c..137ea4cf 100644 --- a/lib/l10n/generated/app_localizations_de.dart +++ b/lib/l10n/generated/app_localizations_de.dart @@ -2925,4 +2925,52 @@ class AppLocalizationsDe extends AppLocalizations { }); return '$frequency $_temp0 $days in $months'; } + + @override + String get searchFilters => 'Suchfilter'; + + @override + String get searchType => 'Eintragstyp'; + + @override + String get searchDate => 'Datum'; + + @override + String get searchAnyDate => 'Beliebiges Datum'; + + @override + String get searchThisWeek => 'Diese Woche'; + + @override + String get searchCustomRange => 'Eigener Zeitraum'; + + @override + String get searchTaskStatus => 'Aufgabenstatus'; + + @override + String get searchTaskDue => 'Aufgabenfälligkeit'; + + @override + String get searchAnyDueState => 'Beliebige Fälligkeit'; + + @override + String get searchNoDueDate => 'Kein Fälligkeitsdatum'; + + @override + String get searchPerson => 'Beteiligte Person'; + + @override + String get searchSources => 'Quellen'; + + @override + String get searchClearFilters => 'Filter zurücksetzen'; + + @override + String get searchFiltersAction => 'Suchfilter öffnen'; + + @override + String get searchNoSources => 'Keine Suchquellen ausgewählt'; + + @override + String get searchClearText => 'Text löschen'; } diff --git a/lib/l10n/generated/app_localizations_en.dart b/lib/l10n/generated/app_localizations_en.dart index 60d03fc6..d3901c5f 100644 --- a/lib/l10n/generated/app_localizations_en.dart +++ b/lib/l10n/generated/app_localizations_en.dart @@ -2957,4 +2957,52 @@ class AppLocalizationsEn extends AppLocalizations { }); return '$frequency $_temp0'; } + + @override + String get searchFilters => 'Search filters'; + + @override + String get searchType => 'Type'; + + @override + String get searchDate => 'Date'; + + @override + String get searchAnyDate => 'Any date'; + + @override + String get searchThisWeek => 'This week'; + + @override + String get searchCustomRange => 'Custom range'; + + @override + String get searchTaskStatus => 'Task status'; + + @override + String get searchTaskDue => 'Task due'; + + @override + String get searchAnyDueState => 'Any due state'; + + @override + String get searchNoDueDate => 'No due date'; + + @override + String get searchPerson => 'Person'; + + @override + String get searchSources => 'Sources'; + + @override + String get searchClearFilters => 'Clear filters'; + + @override + String get searchFiltersAction => 'Filters'; + + @override + String get searchNoSources => 'No search sources selected'; + + @override + String get searchClearText => 'Clear text'; } diff --git a/lib/l10n/generated/app_localizations_es.dart b/lib/l10n/generated/app_localizations_es.dart index 82241326..9044ace8 100644 --- a/lib/l10n/generated/app_localizations_es.dart +++ b/lib/l10n/generated/app_localizations_es.dart @@ -2929,4 +2929,52 @@ class AppLocalizationsEs extends AppLocalizations { }); return '$frequency $_temp0'; } + + @override + String get searchFilters => 'Filtros de búsqueda'; + + @override + String get searchType => 'Tipo'; + + @override + String get searchDate => 'Fecha'; + + @override + String get searchAnyDate => 'Cualquier fecha'; + + @override + String get searchThisWeek => 'Esta semana'; + + @override + String get searchCustomRange => 'Intervalo personalizado'; + + @override + String get searchTaskStatus => 'Estado de tareas'; + + @override + String get searchTaskDue => 'Vencimiento de tareas'; + + @override + String get searchAnyDueState => 'Cualquier vencimiento'; + + @override + String get searchNoDueDate => 'Sin fecha de vencimiento'; + + @override + String get searchPerson => 'Persona'; + + @override + String get searchSources => 'Fuentes'; + + @override + String get searchClearFilters => 'Restablecer filtros'; + + @override + String get searchFiltersAction => 'Filtros'; + + @override + String get searchNoSources => 'No hay fuentes de búsqueda seleccionadas'; + + @override + String get searchClearText => 'Borrar texto'; } diff --git a/lib/l10n/generated/app_localizations_et.dart b/lib/l10n/generated/app_localizations_et.dart index bcfd9e8c..ed5c197a 100644 --- a/lib/l10n/generated/app_localizations_et.dart +++ b/lib/l10n/generated/app_localizations_et.dart @@ -2904,4 +2904,52 @@ class AppLocalizationsEt extends AppLocalizations { }); return '$frequency $months: $_temp0 $days'; } + + @override + String get searchFilters => 'Otsingufiltrid'; + + @override + String get searchType => 'Tüüp'; + + @override + String get searchDate => 'Kuupäev'; + + @override + String get searchAnyDate => 'Kõik kuupäevad'; + + @override + String get searchThisWeek => 'See nädal'; + + @override + String get searchCustomRange => 'Kohandatud vahemik'; + + @override + String get searchTaskStatus => 'Ülesande olek'; + + @override + String get searchTaskDue => 'Ülesande tähtaeg'; + + @override + String get searchAnyDueState => 'Kõik tähtajad'; + + @override + String get searchNoDueDate => 'Tähtajata'; + + @override + String get searchPerson => 'Isik'; + + @override + String get searchSources => 'Allikad'; + + @override + String get searchClearFilters => 'Lähtesta filtrid'; + + @override + String get searchFiltersAction => 'Filtrid'; + + @override + String get searchNoSources => 'Otsinguallikaid pole valitud'; + + @override + String get searchClearText => 'Tühjenda tekst'; } diff --git a/lib/l10n/generated/app_localizations_fa.dart b/lib/l10n/generated/app_localizations_fa.dart index 9803f4b9..91d22c82 100644 --- a/lib/l10n/generated/app_localizations_fa.dart +++ b/lib/l10n/generated/app_localizations_fa.dart @@ -2960,4 +2960,52 @@ class AppLocalizationsFa extends AppLocalizations { }); return '$frequency در $_temp0 $days ماه‌های $months'; } + + @override + String get searchFilters => 'فیلترهای جستجو'; + + @override + String get searchType => 'نوع'; + + @override + String get searchDate => 'تاریخ'; + + @override + String get searchAnyDate => 'هر تاریخی'; + + @override + String get searchThisWeek => 'این هفته'; + + @override + String get searchCustomRange => 'بازهٔ دلخواه'; + + @override + String get searchTaskStatus => 'وضعیت کار'; + + @override + String get searchTaskDue => 'سررسید کار'; + + @override + String get searchAnyDueState => 'هر وضعیت سررسید'; + + @override + String get searchNoDueDate => 'بدون تاریخ سررسید'; + + @override + String get searchPerson => 'شخص'; + + @override + String get searchSources => 'منابع'; + + @override + String get searchClearFilters => 'بازنشانی فیلترها'; + + @override + String get searchFiltersAction => 'فیلترها'; + + @override + String get searchNoSources => 'هیچ منبع جستجویی انتخاب نشده است'; + + @override + String get searchClearText => 'پاک کردن متن'; } diff --git a/lib/l10n/generated/app_localizations_fi.dart b/lib/l10n/generated/app_localizations_fi.dart index 95253e9a..ab13931c 100644 --- a/lib/l10n/generated/app_localizations_fi.dart +++ b/lib/l10n/generated/app_localizations_fi.dart @@ -2954,4 +2954,52 @@ class AppLocalizationsFi extends AppLocalizations { }); return '$frequency $months $_temp0 $days'; } + + @override + String get searchFilters => 'Hakusuodattimet'; + + @override + String get searchType => 'Tyyppi'; + + @override + String get searchDate => 'Päivämäärä'; + + @override + String get searchAnyDate => 'Kaikki päivämäärät'; + + @override + String get searchThisWeek => 'Tämä viikko'; + + @override + String get searchCustomRange => 'Mukautettu aikaväli'; + + @override + String get searchTaskStatus => 'Tehtävän tila'; + + @override + String get searchTaskDue => 'Tehtävän erääntyminen'; + + @override + String get searchAnyDueState => 'Kaikki erääntymistilat'; + + @override + String get searchNoDueDate => 'Ei eräpäivää'; + + @override + String get searchPerson => 'Henkilö'; + + @override + String get searchSources => 'Lähteet'; + + @override + String get searchClearFilters => 'Nollaa suodattimet'; + + @override + String get searchFiltersAction => 'Suodattimet'; + + @override + String get searchNoSources => 'Hakulähteitä ei ole valittu'; + + @override + String get searchClearText => 'Tyhjennä teksti'; } diff --git a/lib/l10n/generated/app_localizations_fr.dart b/lib/l10n/generated/app_localizations_fr.dart index a364fcb4..2afc94d0 100644 --- a/lib/l10n/generated/app_localizations_fr.dart +++ b/lib/l10n/generated/app_localizations_fr.dart @@ -2930,4 +2930,52 @@ class AppLocalizationsFr extends AppLocalizations { }); return '$frequency $_temp0'; } + + @override + String get searchFilters => 'Filtres de recherche'; + + @override + String get searchType => 'Type d’élément'; + + @override + String get searchDate => 'Date de recherche'; + + @override + String get searchAnyDate => 'Toutes les dates'; + + @override + String get searchThisWeek => 'Cette semaine'; + + @override + String get searchCustomRange => 'Période personnalisée'; + + @override + String get searchTaskStatus => 'État des tâches'; + + @override + String get searchTaskDue => 'Échéance des tâches'; + + @override + String get searchAnyDueState => 'Toute échéance'; + + @override + String get searchNoDueDate => 'Sans échéance'; + + @override + String get searchPerson => 'Personne'; + + @override + String get searchSources => 'Sources de recherche'; + + @override + String get searchClearFilters => 'Réinitialiser les filtres'; + + @override + String get searchFiltersAction => 'Filtres'; + + @override + String get searchNoSources => 'Aucune source de recherche sélectionnée'; + + @override + String get searchClearText => 'Effacer le texte'; } diff --git a/lib/l10n/generated/app_localizations_hi.dart b/lib/l10n/generated/app_localizations_hi.dart index ea176ad1..8646139e 100644 --- a/lib/l10n/generated/app_localizations_hi.dart +++ b/lib/l10n/generated/app_localizations_hi.dart @@ -2925,4 +2925,52 @@ class AppLocalizationsHi extends AppLocalizations { }); return '$frequency $months के $_temp0 $days को'; } + + @override + String get searchFilters => 'खोज फ़िल्टर'; + + @override + String get searchType => 'प्रकार'; + + @override + String get searchDate => 'तारीख'; + + @override + String get searchAnyDate => 'कोई भी तारीख'; + + @override + String get searchThisWeek => 'इस सप्ताह'; + + @override + String get searchCustomRange => 'कस्टम अवधि'; + + @override + String get searchTaskStatus => 'कार्य स्थिति'; + + @override + String get searchTaskDue => 'कार्य की नियत तारीख'; + + @override + String get searchAnyDueState => 'कोई भी नियत स्थिति'; + + @override + String get searchNoDueDate => 'बिना नियत तारीख'; + + @override + String get searchPerson => 'व्यक्ति'; + + @override + String get searchSources => 'स्रोत'; + + @override + String get searchClearFilters => 'फ़िल्टर रीसेट करें'; + + @override + String get searchFiltersAction => 'फ़िल्टर'; + + @override + String get searchNoSources => 'कोई खोज स्रोत नहीं चुना गया'; + + @override + String get searchClearText => 'पाठ साफ़ करें'; } diff --git a/lib/l10n/generated/app_localizations_it.dart b/lib/l10n/generated/app_localizations_it.dart index 33bd59b7..fcd500a5 100644 --- a/lib/l10n/generated/app_localizations_it.dart +++ b/lib/l10n/generated/app_localizations_it.dart @@ -2946,4 +2946,52 @@ class AppLocalizationsIt extends AppLocalizations { }); return '$frequency $_temp0'; } + + @override + String get searchFilters => 'Filtri di ricerca'; + + @override + String get searchType => 'Tipo'; + + @override + String get searchDate => 'Data'; + + @override + String get searchAnyDate => 'Qualsiasi data'; + + @override + String get searchThisWeek => 'Questa settimana'; + + @override + String get searchCustomRange => 'Intervallo personalizzato'; + + @override + String get searchTaskStatus => 'Stato attività'; + + @override + String get searchTaskDue => 'Scadenza attività'; + + @override + String get searchAnyDueState => 'Qualsiasi scadenza'; + + @override + String get searchNoDueDate => 'Senza scadenza'; + + @override + String get searchPerson => 'Persona'; + + @override + String get searchSources => 'Origini'; + + @override + String get searchClearFilters => 'Reimposta filtri'; + + @override + String get searchFiltersAction => 'Filtri'; + + @override + String get searchNoSources => 'Nessuna origine di ricerca selezionata'; + + @override + String get searchClearText => 'Cancella testo'; } diff --git a/lib/l10n/generated/app_localizations_ja.dart b/lib/l10n/generated/app_localizations_ja.dart index 2466f1be..89b47b6a 100644 --- a/lib/l10n/generated/app_localizations_ja.dart +++ b/lib/l10n/generated/app_localizations_ja.dart @@ -2862,4 +2862,52 @@ class AppLocalizationsJa extends AppLocalizations { }); return '$frequency$monthsの$_temp0'; } + + @override + String get searchFilters => '検索フィルター'; + + @override + String get searchType => '種類'; + + @override + String get searchDate => '日付'; + + @override + String get searchAnyDate => 'すべての日付'; + + @override + String get searchThisWeek => '今週'; + + @override + String get searchCustomRange => '期間を指定'; + + @override + String get searchTaskStatus => 'タスクの状態'; + + @override + String get searchTaskDue => 'タスクの期限'; + + @override + String get searchAnyDueState => 'すべての期限状態'; + + @override + String get searchNoDueDate => '期限なし'; + + @override + String get searchPerson => '人物'; + + @override + String get searchSources => '検索元'; + + @override + String get searchClearFilters => 'フィルターをリセット'; + + @override + String get searchFiltersAction => 'フィルター'; + + @override + String get searchNoSources => '検索元が選択されていません'; + + @override + String get searchClearText => 'テキストを消去'; } diff --git a/lib/l10n/generated/app_localizations_ko.dart b/lib/l10n/generated/app_localizations_ko.dart index 69ce9937..2833fa62 100644 --- a/lib/l10n/generated/app_localizations_ko.dart +++ b/lib/l10n/generated/app_localizations_ko.dart @@ -2866,4 +2866,52 @@ class AppLocalizationsKo extends AppLocalizations { }); return '$frequency $months의 $_temp0'; } + + @override + String get searchFilters => '검색 필터'; + + @override + String get searchType => '유형'; + + @override + String get searchDate => '날짜'; + + @override + String get searchAnyDate => '모든 날짜'; + + @override + String get searchThisWeek => '이번 주'; + + @override + String get searchCustomRange => '사용자 지정 기간'; + + @override + String get searchTaskStatus => '작업 상태'; + + @override + String get searchTaskDue => '작업 기한'; + + @override + String get searchAnyDueState => '모든 기한 상태'; + + @override + String get searchNoDueDate => '기한 없음'; + + @override + String get searchPerson => '사람'; + + @override + String get searchSources => '검색 소스'; + + @override + String get searchClearFilters => '필터 초기화'; + + @override + String get searchFiltersAction => '필터'; + + @override + String get searchNoSources => '선택한 검색 소스가 없습니다'; + + @override + String get searchClearText => '텍스트 지우기'; } diff --git a/lib/l10n/generated/app_localizations_pl.dart b/lib/l10n/generated/app_localizations_pl.dart index dc2fd2b9..2dcee41f 100644 --- a/lib/l10n/generated/app_localizations_pl.dart +++ b/lib/l10n/generated/app_localizations_pl.dart @@ -3099,4 +3099,52 @@ class AppLocalizationsPl extends AppLocalizations { }); return '$frequency, $_temp0'; } + + @override + String get searchFilters => 'Filtry wyszukiwania'; + + @override + String get searchType => 'Typ'; + + @override + String get searchDate => 'Data'; + + @override + String get searchAnyDate => 'Dowolna data'; + + @override + String get searchThisWeek => 'Ten tydzień'; + + @override + String get searchCustomRange => 'Zakres niestandardowy'; + + @override + String get searchTaskStatus => 'Stan zadania'; + + @override + String get searchTaskDue => 'Termin zadania'; + + @override + String get searchAnyDueState => 'Dowolny stan terminu'; + + @override + String get searchNoDueDate => 'Bez terminu'; + + @override + String get searchPerson => 'Osoba'; + + @override + String get searchSources => 'Źródła'; + + @override + String get searchClearFilters => 'Resetuj filtry'; + + @override + String get searchFiltersAction => 'Filtry'; + + @override + String get searchNoSources => 'Nie wybrano źródeł wyszukiwania'; + + @override + String get searchClearText => 'Wyczyść tekst'; } diff --git a/lib/l10n/generated/app_localizations_pt.dart b/lib/l10n/generated/app_localizations_pt.dart index 4dc2de3c..50058a64 100644 --- a/lib/l10n/generated/app_localizations_pt.dart +++ b/lib/l10n/generated/app_localizations_pt.dart @@ -2957,6 +2957,54 @@ class AppLocalizationsPt extends AppLocalizations { }); return '$frequency $_temp0'; } + + @override + String get searchFilters => 'Search filters'; + + @override + String get searchType => 'Type'; + + @override + String get searchDate => 'Date'; + + @override + String get searchAnyDate => 'Any date'; + + @override + String get searchThisWeek => 'This week'; + + @override + String get searchCustomRange => 'Custom range'; + + @override + String get searchTaskStatus => 'Task status'; + + @override + String get searchTaskDue => 'Task due'; + + @override + String get searchAnyDueState => 'Any due state'; + + @override + String get searchNoDueDate => 'No due date'; + + @override + String get searchPerson => 'Person'; + + @override + String get searchSources => 'Sources'; + + @override + String get searchClearFilters => 'Clear filters'; + + @override + String get searchFiltersAction => 'Filters'; + + @override + String get searchNoSources => 'No search sources selected'; + + @override + String get searchClearText => 'Clear text'; } /// The translations for Portuguese, as used in Portugal (`pt_PT`). @@ -5896,4 +5944,52 @@ class AppLocalizationsPtPt extends AppLocalizationsPt { }); return '$frequency $_temp0'; } + + @override + String get searchFilters => 'Filtros de pesquisa'; + + @override + String get searchType => 'Tipo'; + + @override + String get searchDate => 'Data'; + + @override + String get searchAnyDate => 'Qualquer data'; + + @override + String get searchThisWeek => 'Esta semana'; + + @override + String get searchCustomRange => 'Intervalo personalizado'; + + @override + String get searchTaskStatus => 'Estado das tarefas'; + + @override + String get searchTaskDue => 'Prazo das tarefas'; + + @override + String get searchAnyDueState => 'Qualquer prazo'; + + @override + String get searchNoDueDate => 'Sem data de conclusão'; + + @override + String get searchPerson => 'Pessoa'; + + @override + String get searchSources => 'Origens'; + + @override + String get searchClearFilters => 'Repor filtros'; + + @override + String get searchFiltersAction => 'Filtros'; + + @override + String get searchNoSources => 'Nenhuma origem de pesquisa selecionada'; + + @override + String get searchClearText => 'Limpar texto'; } diff --git a/lib/l10n/generated/app_localizations_ru.dart b/lib/l10n/generated/app_localizations_ru.dart index 102871b6..7e4679a0 100644 --- a/lib/l10n/generated/app_localizations_ru.dart +++ b/lib/l10n/generated/app_localizations_ru.dart @@ -2949,4 +2949,52 @@ class AppLocalizationsRu extends AppLocalizations { }); return '$frequency: $months, $_temp0'; } + + @override + String get searchFilters => 'Фильтры поиска'; + + @override + String get searchType => 'Тип'; + + @override + String get searchDate => 'Дата'; + + @override + String get searchAnyDate => 'Любая дата'; + + @override + String get searchThisWeek => 'Эта неделя'; + + @override + String get searchCustomRange => 'Свой диапазон'; + + @override + String get searchTaskStatus => 'Статус задач'; + + @override + String get searchTaskDue => 'Срок задач'; + + @override + String get searchAnyDueState => 'Любой срок'; + + @override + String get searchNoDueDate => 'Без срока'; + + @override + String get searchPerson => 'Участник'; + + @override + String get searchSources => 'Источники'; + + @override + String get searchClearFilters => 'Сбросить фильтры'; + + @override + String get searchFiltersAction => 'Фильтры'; + + @override + String get searchNoSources => 'Источники поиска не выбраны'; + + @override + String get searchClearText => 'Очистить текст'; } diff --git a/lib/l10n/generated/app_localizations_vi.dart b/lib/l10n/generated/app_localizations_vi.dart index 5d471410..5d61a3e5 100644 --- a/lib/l10n/generated/app_localizations_vi.dart +++ b/lib/l10n/generated/app_localizations_vi.dart @@ -2922,4 +2922,52 @@ class AppLocalizationsVi extends AppLocalizations { }); return '$frequency vào $days $_temp0 của $months'; } + + @override + String get searchFilters => 'Bộ lọc tìm kiếm'; + + @override + String get searchType => 'Loại'; + + @override + String get searchDate => 'Ngày'; + + @override + String get searchAnyDate => 'Mọi ngày'; + + @override + String get searchThisWeek => 'Tuần này'; + + @override + String get searchCustomRange => 'Khoảng tùy chọn'; + + @override + String get searchTaskStatus => 'Trạng thái công việc'; + + @override + String get searchTaskDue => 'Hạn công việc'; + + @override + String get searchAnyDueState => 'Mọi trạng thái hạn'; + + @override + String get searchNoDueDate => 'Không có hạn'; + + @override + String get searchPerson => 'Người'; + + @override + String get searchSources => 'Nguồn'; + + @override + String get searchClearFilters => 'Đặt lại bộ lọc'; + + @override + String get searchFiltersAction => 'Bộ lọc'; + + @override + String get searchNoSources => 'Chưa chọn nguồn tìm kiếm'; + + @override + String get searchClearText => 'Xóa văn bản'; } diff --git a/lib/l10n/generated/app_localizations_zh.dart b/lib/l10n/generated/app_localizations_zh.dart index 15508cd9..67563baf 100644 --- a/lib/l10n/generated/app_localizations_zh.dart +++ b/lib/l10n/generated/app_localizations_zh.dart @@ -2825,6 +2825,54 @@ class AppLocalizationsZh extends AppLocalizations { }); return '$frequency$months的$_temp0'; } + + @override + String get searchFilters => '搜索筛选条件'; + + @override + String get searchType => '类型'; + + @override + String get searchDate => '日期'; + + @override + String get searchAnyDate => '任何日期'; + + @override + String get searchThisWeek => '本周'; + + @override + String get searchCustomRange => '自定义范围'; + + @override + String get searchTaskStatus => '任务状态'; + + @override + String get searchTaskDue => '任务到期状态'; + + @override + String get searchAnyDueState => '任何到期状态'; + + @override + String get searchNoDueDate => '无截止日期'; + + @override + String get searchPerson => '人员'; + + @override + String get searchSources => '来源'; + + @override + String get searchClearFilters => '重置筛选条件'; + + @override + String get searchFiltersAction => '筛选'; + + @override + String get searchNoSources => '未选择搜索来源'; + + @override + String get searchClearText => '清除文字'; } /// The translations for Chinese, using the Han script (`zh_Hans`). @@ -5644,6 +5692,54 @@ class AppLocalizationsZhHans extends AppLocalizationsZh { }); return '$frequency$months的$_temp0'; } + + @override + String get searchFilters => '搜索筛选条件'; + + @override + String get searchType => '类型'; + + @override + String get searchDate => '日期'; + + @override + String get searchAnyDate => '任何日期'; + + @override + String get searchThisWeek => '本周'; + + @override + String get searchCustomRange => '自定义范围'; + + @override + String get searchTaskStatus => '任务状态'; + + @override + String get searchTaskDue => '任务到期状态'; + + @override + String get searchAnyDueState => '任何到期状态'; + + @override + String get searchNoDueDate => '无截止日期'; + + @override + String get searchPerson => '人员'; + + @override + String get searchSources => '来源'; + + @override + String get searchClearFilters => '重置筛选条件'; + + @override + String get searchFiltersAction => '筛选'; + + @override + String get searchNoSources => '未选择搜索来源'; + + @override + String get searchClearText => '清除文字'; } /// The translations for Chinese, using the Han script (`zh_Hant`). @@ -8464,4 +8560,52 @@ class AppLocalizationsZhHant extends AppLocalizationsZh { }); return '$frequency$months的$_temp0'; } + + @override + String get searchFilters => '搜尋篩選條件'; + + @override + String get searchType => '類型'; + + @override + String get searchDate => '日期'; + + @override + String get searchAnyDate => '任何日期'; + + @override + String get searchThisWeek => '本週'; + + @override + String get searchCustomRange => '自訂範圍'; + + @override + String get searchTaskStatus => '工作狀態'; + + @override + String get searchTaskDue => '工作到期狀態'; + + @override + String get searchAnyDueState => '任何到期狀態'; + + @override + String get searchNoDueDate => '無截止日期'; + + @override + String get searchPerson => '人員'; + + @override + String get searchSources => '來源'; + + @override + String get searchClearFilters => '重設篩選條件'; + + @override + String get searchFiltersAction => '篩選'; + + @override + String get searchNoSources => '未選取搜尋來源'; + + @override + String get searchClearText => '清除文字'; } diff --git a/lib/src/android/presentation/android_schedule_screen.dart b/lib/src/android/presentation/android_schedule_screen.dart index bee6bdd0..87e9aecd 100644 --- a/lib/src/android/presentation/android_schedule_screen.dart +++ b/lib/src/android/presentation/android_schedule_screen.dart @@ -26,6 +26,10 @@ import '../../schedule/schedule_item.dart'; import '../../schedule/schedule_projection.dart'; import '../../schedule/schedule_range.dart'; import '../../schedule/schedule_view_mode.dart'; +import '../../schedule/schedule_source_visibility.dart'; +import '../../schedule/schedule_search_criteria.dart'; +import 'android_schedule_search_filters.dart'; +import '../../features/schedule/presentation/schedule_search_result_text.dart'; import '../android_notifications.dart'; import 'android_availability_dialog.dart'; import 'android_settings_screen.dart'; @@ -34,7 +38,13 @@ import 'android_tasks_screen.dart'; final _androidScheduleItemsProvider = FutureProvider.autoDispose .family< List, - ({DateTime anchor, ScheduleViewMode mode, String query, int firstWeekday}) + ({ + DateTime anchor, + ScheduleViewMode mode, + String query, + int firstWeekday, + ScheduleSearchCriteria? search, + }) >((ref, key) async { ref.watch(scheduleDataRevisionProvider); final sources = await ref.watch(calendarSourcesStreamProvider.future); @@ -43,33 +53,33 @@ final _androidScheduleItemsProvider = FutureProvider.autoDispose return ref .watch(scheduleRepositoryProvider) .listItems( - range: _rangeFor( - key.anchor, - key.mode, - firstWeekday: key.firstWeekday, - ), - filters: ScheduleFilters( - query: key.query, - sourceIds: { - for (final source in sources) - if (source.selected) source.id, - }, - taskListKeys: { - for (final list in lists) - if (settings.isTaskListVisibleInSchedule( - list.accountId, - list.id, - )) - ScheduleTaskListKey( - accountId: list.accountId, - taskListId: list.id, - ), - }, - sourceFilterActive: true, - taskListFilterActive: true, - showCompletedTasks: true, - showNoDateTasks: key.mode == ScheduleViewMode.agenda, - ), + range: + key.search?.range ?? + _rangeFor(key.anchor, key.mode, firstWeekday: key.firstWeekday), + filters: + key.search?.filters(key.query) ?? + ScheduleFilters( + query: key.query, + sourceIds: { + for (final source in sources) + if (source.selected) source.id, + }, + taskListKeys: { + for (final list in lists) + if (settings.isTaskListVisibleInSchedule( + list.accountId, + list.id, + )) + ScheduleTaskListKey( + accountId: list.accountId, + taskListId: list.id, + ), + }, + sourceFilterActive: true, + taskListFilterActive: true, + taskCompletion: ScheduleTaskCompletion.all, + showNoDateTasks: key.mode == ScheduleViewMode.agenda, + ), ); }); @@ -85,6 +95,15 @@ class _AndroidScheduleScreenState extends ConsumerState { DateTime _anchor = DateTime.now(); bool _searching = false; String _query = ''; + final _searchController = TextEditingController(); + ScheduleSearchCriteria? _searchCriteria; + ScheduleSearchCriteria? _initialSearchCriteria; + + @override + void dispose() { + _searchController.dispose(); + super.dispose(); + } @override Widget build(BuildContext context) { @@ -95,6 +114,7 @@ class _AndroidScheduleScreenState extends ConsumerState { anchor: DateTime(_anchor.year, _anchor.month, _anchor.day), mode: mode, query: _query, + search: _searching ? _searchCriteria : null, firstWeekday: _firstWeekday(context), )), ); @@ -102,10 +122,21 @@ class _AndroidScheduleScreenState extends ConsumerState { appBar: AppBar( title: _searching ? TextField( + controller: _searchController, autofocus: true, decoration: InputDecoration( hintText: context.l10n.windowsSearch, border: InputBorder.none, + suffixIcon: IconButton( + tooltip: MaterialLocalizations.of( + context, + ).clearButtonTooltip, + icon: const Icon(Icons.clear), + onPressed: () { + _searchController.clear(); + setState(() => _query = ''); + }, + ), ), onChanged: (value) => setState(() => _query = value), ) @@ -126,66 +157,70 @@ class _AndroidScheduleScreenState extends ConsumerState { actions: [ IconButton( tooltip: context.l10n.windowsSearch, - onPressed: () => setState(() { - _searching = !_searching; - if (!_searching) _query = ''; - }), + onPressed: _searching ? _closeSearch : _openSearch, icon: Icon(_searching ? Icons.close : Icons.search), ), IconButton( - tooltip: context.l10n.collectionSettings, - onPressed: () => _showSources(context), + tooltip: _searching + ? context.l10n.searchFiltersAction + : context.l10n.collectionSettings, + onPressed: () => + _searching ? _showSearchFilters() : _showSources(context), icon: const Icon(Icons.tune), ), - PopupMenuButton( - tooltip: context.l10n.scheduleDisplaySettings, - initialValue: mode, - onSelected: ref - .read(appSettingsControllerProvider.notifier) - .setAndroidScheduleViewMode, - itemBuilder: (context) => [ - for (final candidate in ScheduleViewMode.values) - PopupMenuItem( - value: candidate, - child: Text(_modeLabel(context, candidate)), - ), - ], - ), - ], - bottom: PreferredSize( - preferredSize: const Size.fromHeight(48), - child: Padding( - padding: const EdgeInsets.fromLTRB(8, 0, 8, 8), - child: Row( - children: [ - IconButton( - tooltip: context.l10n.shortcutPreviousPeriod, - onPressed: () => - setState(() => _anchor = _move(_anchor, mode, -1)), - icon: const Icon(Icons.chevron_left), - ), - FilledButton.tonal( - onPressed: () => setState(() => _anchor = DateTime.now()), - child: Text(context.l10n.today), - ), - IconButton( - tooltip: context.l10n.shortcutNextPeriod, - onPressed: () => - setState(() => _anchor = _move(_anchor, mode, 1)), - icon: const Icon(Icons.chevron_right), - ), - const Spacer(), - Flexible( - child: Text( - _modeLabel(context, mode), - maxLines: 1, - overflow: TextOverflow.ellipsis, + if (!_searching) + PopupMenuButton( + tooltip: context.l10n.scheduleDisplaySettings, + initialValue: mode, + onSelected: ref + .read(appSettingsControllerProvider.notifier) + .setAndroidScheduleViewMode, + itemBuilder: (context) => [ + for (final candidate in ScheduleViewMode.values) + PopupMenuItem( + value: candidate, + child: Text(_modeLabel(context, candidate)), ), - ), ], ), - ), - ), + ], + bottom: _searching + ? null + : PreferredSize( + preferredSize: const Size.fromHeight(48), + child: Padding( + padding: const EdgeInsets.fromLTRB(8, 0, 8, 8), + child: Row( + children: [ + IconButton( + tooltip: context.l10n.shortcutPreviousPeriod, + onPressed: () => + setState(() => _anchor = _move(_anchor, mode, -1)), + icon: const Icon(Icons.chevron_left), + ), + FilledButton.tonal( + onPressed: () => + setState(() => _anchor = DateTime.now()), + child: Text(context.l10n.today), + ), + IconButton( + tooltip: context.l10n.shortcutNextPeriod, + onPressed: () => + setState(() => _anchor = _move(_anchor, mode, 1)), + icon: const Icon(Icons.chevron_right), + ), + const Spacer(), + Flexible( + child: Text( + _modeLabel(context, mode), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + ), ), body: items.when( loading: () => const Center(child: CircularProgressIndicator()), @@ -195,17 +230,27 @@ class _AndroidScheduleScreenState extends ConsumerState { detail: '$error', ), data: (value) => - value.isEmpty && - (_query.isNotEmpty || mode == ScheduleViewMode.agenda) + value.isEmpty && (_searching || mode == ScheduleViewMode.agenda) ? _Message( - icon: _query.isEmpty ? Icons.event_busy : Icons.search_off, - title: _query.isEmpty + icon: !_searching ? Icons.event_busy : Icons.search_off, + title: !_searching ? context.l10n.noEventsOrTasks + : _searchCriteria?.hasSources == false + ? context.l10n.searchNoSources : context.l10n.scheduleNoSearchResults, - detail: _query.isEmpty + detail: !_searching ? context.l10n.noEventsOrTasks : context.l10n.scheduleNoSearchResultsDescription, ) + : _searching + ? _AgendaList( + key: const ValueKey('android-search-results'), + items: value, + onOpen: (item) => _showItem(context, item), + onToggleTask: _toggleTask, + searchCriteria: _searchCriteria, + searchQuery: _query, + ) : _ScheduleBody( anchor: _anchor, mode: mode, @@ -229,6 +274,77 @@ class _AndroidScheduleScreenState extends ConsumerState { ); } + Future _openSearch() async { + final sources = await ref.read(calendarSourcesStreamProvider.future); + final lists = await ref.read(scheduleTaskListsProvider.future); + if (!mounted) return; + final visibility = ScheduleSourceVisibility.fromSources( + calendarSources: sources, + taskLists: lists, + settings: ref.read(appSettingsControllerProvider), + ); + final now = DateTime.now(); + setState(() { + _initialSearchCriteria = ScheduleSearchCriteria( + referenceDate: DateTime(now.year, now.month, now.day), + firstWeekday: _firstWeekday(context), + sourceIds: visibility.visibleCalendarSourceIds, + taskListKeys: visibility.visibleTaskListKeys, + ); + _searchCriteria = _initialSearchCriteria; + _searching = true; + }); + } + + void _closeSearch() { + setState(() { + _searching = false; + _searchCriteria = null; + _initialSearchCriteria = null; + _query = ''; + _searchController.clear(); + }); + } + + Future _showSearchFilters() async { + if (_searchCriteria == null) return; + await showModalBottomSheet( + context: context, + showDragHandle: true, + isScrollControlled: true, + builder: (context) => StatefulBuilder( + builder: (context, update) => SafeArea( + child: Padding( + padding: EdgeInsetsDirectional.only( + bottom: MediaQuery.viewInsetsOf(context).bottom, + ), + child: SizedBox( + height: MediaQuery.sizeOf(context).height * .8, + child: AndroidScheduleSearchFilters( + value: _searchCriteria!, + accounts: + ref.read(accountsStreamProvider).valueOrNull ?? const [], + sources: + ref.read(calendarSourcesStreamProvider).valueOrNull ?? + const [], + taskLists: + ref.read(scheduleTaskListsProvider).valueOrNull ?? const [], + onChanged: (value) { + setState(() => _searchCriteria = value); + update(() {}); + }, + onClear: () { + setState(() => _searchCriteria = _initialSearchCriteria); + update(() {}); + }, + ), + ), + ), + ), + ), + ); + } + Future _showSources(BuildContext context) async { await showModalBottomSheet( context: context, @@ -751,10 +867,15 @@ class _ScheduleBody extends StatelessWidget { class _AgendaList extends StatelessWidget { const _AgendaList({ + super.key, + this.searchCriteria, + this.searchQuery = '', required this.items, required this.onOpen, required this.onToggleTask, }); + final ScheduleSearchCriteria? searchCriteria; + final String searchQuery; final List items; final ValueChanged onOpen; final ValueChanged onToggleTask; @@ -822,7 +943,16 @@ class _AgendaList extends StatelessWidget { overflow: TextOverflow.ellipsis, ), subtitle: Text( - '$time · ${item.sourceName ?? item.provider.displayName}', + searchCriteria != null + ? scheduleSearchResultText( + context, + item, + searchCriteria!, + searchQuery, + ) + : '$time · ${item.sourceName ?? item.provider.displayName}', + maxLines: 2, + overflow: TextOverflow.ellipsis, ), onTap: () => onOpen(item), ), diff --git a/lib/src/android/presentation/android_schedule_search_filters.dart b/lib/src/android/presentation/android_schedule_search_filters.dart new file mode 100644 index 00000000..41775cad --- /dev/null +++ b/lib/src/android/presentation/android_schedule_search_filters.dart @@ -0,0 +1,259 @@ +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; + +import '../../l10n/l10n.dart'; +import '../../schedule/schedule_search_criteria.dart'; +import '../../schedule/schedule_filters.dart'; +import '../../features/accounts/data/accounts_repository.dart'; +import '../../features/calendar/data/calendar_repository.dart'; +import '../../features/task_lists/data/task_lists_repository.dart'; +import '../../features/schedule/presentation/schedule_search_labels.dart'; + +/// Material search controls for the Android bottom sheet. +/// This widget only emits immutable criteria; it never writes source settings. +class AndroidScheduleSearchFilters extends StatelessWidget { + const AndroidScheduleSearchFilters({ + super.key, + required this.value, + required this.onChanged, + required this.onClear, + required this.accounts, + required this.sources, + required this.taskLists, + }); + final ScheduleSearchCriteria value; + final ValueChanged onChanged; + final VoidCallback onClear; + final List accounts; + final List sources; + final List taskLists; + + @override + Widget build(BuildContext context) { + final l = context.l10n; + return ListView( + key: const ValueKey('schedule-search-filters'), + padding: const EdgeInsetsDirectional.all(16), + children: [ + Text(l.searchFilters, style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 16), + _select( + l.searchType, + value.type, + ScheduleSearchType.values, + (v) => searchTypeLabel(l, v), + (v) => onChanged(value.copyWith(type: v)), + ), + _select( + l.searchDate, + value.date, + ScheduleSearchDate.values, + (v) => searchDateLabel(l, v), + (v) => onChanged(value.copyWith(date: v)), + ), + if (value.date == ScheduleSearchDate.custom) ...[ + _date( + context, + l.startDate, + value.customStart ?? value.referenceDate, + true, + ), + _date( + context, + l.endDate, + value.customEnd ?? value.referenceDate, + false, + ), + ], + if (value.includesTasks) ...[ + _select( + l.searchTaskStatus, + value.taskCompletion, + ScheduleTaskCompletion.values, + (v) => searchCompletionLabel(l, v), + (v) => onChanged(value.copyWith(taskCompletion: v)), + ), + _select( + l.searchTaskDue, + value.taskDueState, + ScheduleTaskDueState.values, + (v) => searchDueLabel(l, v), + (v) => onChanged(value.copyWith(taskDueState: v)), + ), + ], + if (value.includesEvents) + _text( + l.searchPerson, + value.person, + (v) => onChanged(value.copyWith(person: v)), + ), + _text( + l.location, + value.location, + (v) => onChanged(value.copyWith(location: v)), + ), + Text(l.searchSources, style: Theme.of(context).textTheme.titleSmall), + for (final account in accounts) ...[ + Padding( + padding: const EdgeInsetsDirectional.only(top: 12, bottom: 4), + child: Text( + account.displayLabel, + style: Theme.of(context).textTheme.labelLarge, + ), + ), + if (value.includesEvents) + for (final source in sources.where( + (s) => s.accountId == account.id && !s.isDeleted, + )) + CheckboxListTile( + contentPadding: EdgeInsets.zero, + controlAffinity: ListTileControlAffinity.leading, + title: Text(source.summary), + secondary: const Icon(Icons.calendar_month_outlined, size: 18), + value: value.sourceIds.contains(source.id), + onChanged: (selected) { + final ids = {...value.sourceIds}; + if (selected == true) { + ids.add(source.id); + } else { + ids.remove(source.id); + } + onChanged(value.copyWith(sourceIds: ids)); + }, + ), + if (value.includesTasks) + for (final list in taskLists.where( + (s) => s.accountId == account.id && !s.pendingDelete, + )) + CheckboxListTile( + contentPadding: EdgeInsets.zero, + controlAffinity: ListTileControlAffinity.leading, + title: Text(list.title), + secondary: const Icon(Icons.checklist, size: 18), + value: value.taskListKeys.contains( + ScheduleTaskListKey( + accountId: list.accountId, + taskListId: list.id, + ), + ), + onChanged: (selected) { + final keys = {...value.taskListKeys}; + final key = ScheduleTaskListKey( + accountId: list.accountId, + taskListId: list.id, + ); + if (selected == true) { + keys.add(key); + } else { + keys.remove(key); + } + onChanged(value.copyWith(taskListKeys: keys)); + }, + ), + ], + if (!value.hasSources) + Padding( + padding: const EdgeInsetsDirectional.symmetric(vertical: 12), + child: Text(l.searchNoSources), + ), + const SizedBox(height: 12), + TextButton(onPressed: onClear, child: Text(l.searchClearFilters)), + ], + ); + } + + Widget _select( + String label, + T selected, + List values, + String Function(T) name, + ValueChanged changed, + ) => Padding( + padding: const EdgeInsetsDirectional.only(bottom: 12), + child: DropdownButtonFormField( + key: ValueKey((label, selected)), + initialValue: selected, + isExpanded: true, + decoration: InputDecoration(labelText: label), + items: [ + for (final v in values) + DropdownMenuItem(value: v, child: Text(name(v))), + ], + onChanged: (v) { + if (v != null) changed(v); + }, + ), + ); + + Widget _text(String label, String text, ValueChanged changed) => + Padding( + padding: const EdgeInsetsDirectional.only(bottom: 16), + child: _SearchFilterText(label: label, value: text, onChanged: changed), + ); + + Widget _date( + BuildContext context, + String label, + DateTime date, + bool start, + ) => Padding( + padding: const EdgeInsetsDirectional.only(bottom: 8), + child: OutlinedButton( + onPressed: () async { + final picked = await showDatePicker( + context: context, + initialDate: date, + firstDate: DateTime(1900), + lastDate: DateTime(2200), + ); + if (picked == null) return; + final from = start ? picked : value.customStart ?? value.referenceDate; + final to = start ? value.customEnd ?? value.referenceDate : picked; + onChanged( + value.copyWith( + customStart: from.isAfter(to) ? picked : from, + customEnd: to.isBefore(from) ? picked : to, + ), + ); + }, + child: Text( + '$label: ${DateFormat.yMMMd(Localizations.localeOf(context).toLanguageTag()).format(date)}', + ), + ), + ); +} + +class _SearchFilterText extends StatefulWidget { + const _SearchFilterText({ + required this.label, + required this.value, + required this.onChanged, + }); + final String label; + final String value; + final ValueChanged onChanged; + @override + State<_SearchFilterText> createState() => _SearchFilterTextState(); +} + +class _SearchFilterTextState extends State<_SearchFilterText> { + late final _controller = TextEditingController(text: widget.value); + @override + void didUpdateWidget(covariant _SearchFilterText oldWidget) { + super.didUpdateWidget(oldWidget); + if (_controller.text != widget.value) _controller.text = widget.value; + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) => TextField( + controller: _controller, + decoration: InputDecoration(labelText: widget.label), + onChanged: widget.onChanged, + ); +} diff --git a/lib/src/android/presentation/android_tasks_screen.dart b/lib/src/android/presentation/android_tasks_screen.dart index a65c2b5f..6de16b46 100644 --- a/lib/src/android/presentation/android_tasks_screen.dart +++ b/lib/src/android/presentation/android_tasks_screen.dart @@ -46,7 +46,9 @@ final _androidTasksProvider = FutureProvider.autoDispose .listAllTasks( filters: ScheduleFilters( query: key.query, - showCompletedTasks: key.completed, + taskCompletion: key.completed + ? ScheduleTaskCompletion.all + : ScheduleTaskCompletion.open, showNoDateTasks: key.noDate, taskListFilterActive: selected != null, taskListKeys: selected == null diff --git a/lib/src/features/schedule/presentation/schedule_agenda_view.dart b/lib/src/features/schedule/presentation/schedule_agenda_view.dart index b06f6983..926e60e3 100644 --- a/lib/src/features/schedule/presentation/schedule_agenda_view.dart +++ b/lib/src/features/schedule/presentation/schedule_agenda_view.dart @@ -13,6 +13,8 @@ import '../../../schedule/schedule_projection.dart'; import '../../../schedule/schedule_range.dart'; import '../../../schedule/schedule_sorting.dart'; import 'schedule_event_block.dart'; +import 'schedule_search_result_text.dart'; +import '../../../schedule/schedule_search_criteria.dart'; import 'schedule_item_selection.dart'; class ScheduleAgendaView extends StatefulWidget { @@ -29,8 +31,12 @@ class ScheduleAgendaView extends StatefulWidget { this.onLoadMoreOverdue, this.onLoadMoreNoDate, this.onItemAnchorAvailable, + this.searchCriteria, + this.searchQuery = '', }); + final ScheduleSearchCriteria? searchCriteria; + final String searchQuery; final ScheduleRange range; final List items; final ScheduleItemSelectionCallback onItemSelected; @@ -82,6 +88,14 @@ class _ScheduleAgendaViewState extends State { @override Widget build(BuildContext context) { + if (widget.searchCriteria != null) { + return ListView.builder( + key: const ValueKey('schedule-search-results'), + padding: const EdgeInsetsDirectional.all(BusyMaxSpacing.md), + itemCount: widget.items.length, + itemBuilder: (context, index) => _standaloneRow(widget.items[index]), + ); + } final dated = widget.items.where((item) => item.start != null).toList(); final noDateTasks = ScheduleProjection.noDateTasks(widget.items); final groups = ScheduleProjection.groupByDay(dated); @@ -214,6 +228,8 @@ class _ScheduleAgendaViewState extends State { final task = item is TaskScheduleItem ? item : null; return _AgendaRow( item: item, + searchCriteria: widget.searchCriteria, + searchQuery: widget.searchQuery, onAnchorAvailable: widget.onItemAnchorAvailable, onTap: select, onTaskCompletionChanged: task == null @@ -384,9 +400,13 @@ class _AgendaRow extends StatelessWidget { required this.item, required this.onTap, this.onAnchorAvailable, + this.searchCriteria, + this.searchQuery = '', this.onTaskCompletionChanged, }); + final ScheduleSearchCriteria? searchCriteria; + final String searchQuery; final ScheduleItem item; final ScheduleItemTapCallback onTap; final ScheduleItemAnchorCallback? onAnchorAvailable; @@ -407,7 +427,18 @@ class _AgendaRow extends StatelessWidget { return BusyMaxActionRow( title: item.title, titleWidget: _AgendaItemTitle(item: item), - subtitleWidget: _AgendaItemSubtitle(item: item), + subtitleWidget: searchCriteria == null + ? _AgendaItemSubtitle(item: item) + : Text( + scheduleSearchResultText( + context, + item, + searchCriteria!, + searchQuery, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), leading: _AgendaItemMarker(item: item), trailing: task == null ? null diff --git a/lib/src/features/schedule/presentation/schedule_search_filters.dart b/lib/src/features/schedule/presentation/schedule_search_filters.dart new file mode 100644 index 00000000..389a55cf --- /dev/null +++ b/lib/src/features/schedule/presentation/schedule_search_filters.dart @@ -0,0 +1,255 @@ +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; +import 'package:yaru/yaru.dart'; +import '../../../app/busymax_design.dart'; + +import '../../../l10n/l10n.dart'; +import '../../../schedule/schedule_search_criteria.dart'; +import '../../../schedule/schedule_filters.dart'; +import '../../accounts/data/accounts_repository.dart'; +import '../../calendar/data/calendar_repository.dart'; +import '../../task_lists/data/task_lists_repository.dart'; +import 'schedule_search_labels.dart'; + +/// Compact Yaru search controls for the existing Schedule sidebar. +/// This widget only emits immutable criteria; it never writes source settings. +class ScheduleSearchFilters extends StatelessWidget { + const ScheduleSearchFilters({ + super.key, + required this.value, + required this.onChanged, + required this.onClear, + required this.accounts, + required this.sources, + required this.taskLists, + }); + final ScheduleSearchCriteria value; + final ValueChanged onChanged; + final VoidCallback onClear; + final List accounts; + final List sources; + final List taskLists; + + @override + Widget build(BuildContext context) { + final l = context.l10n; + return ListView( + key: const ValueKey('schedule-search-filters'), + padding: const EdgeInsetsDirectional.all(16), + children: [ + Text(l.searchFilters, style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 16), + _select( + l.searchType, + value.type, + ScheduleSearchType.values, + (v) => searchTypeLabel(l, v), + (v) => onChanged(value.copyWith(type: v)), + ), + _select( + l.searchDate, + value.date, + ScheduleSearchDate.values, + (v) => searchDateLabel(l, v), + (v) => onChanged(value.copyWith(date: v)), + ), + if (value.date == ScheduleSearchDate.custom) ...[ + _date( + context, + l.startDate, + value.customStart ?? value.referenceDate, + true, + ), + _date( + context, + l.endDate, + value.customEnd ?? value.referenceDate, + false, + ), + ], + if (value.includesTasks) ...[ + _select( + l.searchTaskStatus, + value.taskCompletion, + ScheduleTaskCompletion.values, + (v) => searchCompletionLabel(l, v), + (v) => onChanged(value.copyWith(taskCompletion: v)), + ), + _select( + l.searchTaskDue, + value.taskDueState, + ScheduleTaskDueState.values, + (v) => searchDueLabel(l, v), + (v) => onChanged(value.copyWith(taskDueState: v)), + ), + ], + if (value.includesEvents) + _text( + l.searchPerson, + value.person, + (v) => onChanged(value.copyWith(person: v)), + ), + _text( + l.location, + value.location, + (v) => onChanged(value.copyWith(location: v)), + ), + Text(l.searchSources, style: Theme.of(context).textTheme.titleSmall), + for (final account in accounts) ...[ + Padding( + padding: const EdgeInsetsDirectional.only(top: 12, bottom: 4), + child: Text( + account.displayLabel, + style: Theme.of(context).textTheme.labelLarge, + ), + ), + if (value.includesEvents) + for (final source in sources.where( + (s) => s.accountId == account.id && !s.isDeleted, + )) + YaruCheckboxListTile( + contentPadding: EdgeInsets.zero, + controlAffinity: ListTileControlAffinity.leading, + title: Text(source.summary), + secondary: const Icon(Icons.calendar_month_outlined, size: 18), + value: value.sourceIds.contains(source.id), + onChanged: (selected) { + final ids = {...value.sourceIds}; + if (selected == true) { + ids.add(source.id); + } else { + ids.remove(source.id); + } + onChanged(value.copyWith(sourceIds: ids)); + }, + ), + if (value.includesTasks) + for (final list in taskLists.where( + (s) => s.accountId == account.id && !s.pendingDelete, + )) + YaruCheckboxListTile( + contentPadding: EdgeInsets.zero, + controlAffinity: ListTileControlAffinity.leading, + title: Text(list.title), + secondary: const Icon(Icons.checklist, size: 18), + value: value.taskListKeys.contains( + ScheduleTaskListKey( + accountId: list.accountId, + taskListId: list.id, + ), + ), + onChanged: (selected) { + final keys = {...value.taskListKeys}; + final key = ScheduleTaskListKey( + accountId: list.accountId, + taskListId: list.id, + ); + if (selected == true) { + keys.add(key); + } else { + keys.remove(key); + } + onChanged(value.copyWith(taskListKeys: keys)); + }, + ), + ], + if (!value.hasSources) + Padding( + padding: const EdgeInsetsDirectional.symmetric(vertical: 12), + child: Text(l.searchNoSources), + ), + const SizedBox(height: 12), + TextButton(onPressed: onClear, child: Text(l.searchClearFilters)), + ], + ); + } + + Widget _select( + String label, + T selected, + List values, + String Function(T) name, + ValueChanged changed, + ) => Padding( + padding: const EdgeInsetsDirectional.only(bottom: 12), + child: BusyMaxComboRow( + title: label, + values: values, + selected: selected, + labelFor: name, + onSelected: changed, + ), + ); + + Widget _text(String label, String text, ValueChanged changed) => + Padding( + padding: const EdgeInsetsDirectional.only(bottom: 16), + child: _SearchFilterText(label: label, value: text, onChanged: changed), + ); + + Widget _date( + BuildContext context, + String label, + DateTime date, + bool start, + ) => Padding( + padding: const EdgeInsetsDirectional.only(bottom: 8), + child: OutlinedButton( + onPressed: () async { + final picked = await showDatePicker( + context: context, + initialDate: date, + firstDate: DateTime(1900), + lastDate: DateTime(2200), + ); + if (picked == null) return; + final from = start ? picked : value.customStart ?? value.referenceDate; + final to = start ? value.customEnd ?? value.referenceDate : picked; + onChanged( + value.copyWith( + customStart: from.isAfter(to) ? picked : from, + customEnd: to.isBefore(from) ? picked : to, + ), + ); + }, + child: Text( + '$label: ${DateFormat.yMMMd(Localizations.localeOf(context).toLanguageTag()).format(date)}', + ), + ), + ); +} + +class _SearchFilterText extends StatefulWidget { + const _SearchFilterText({ + required this.label, + required this.value, + required this.onChanged, + }); + final String label; + final String value; + final ValueChanged onChanged; + @override + State<_SearchFilterText> createState() => _SearchFilterTextState(); +} + +class _SearchFilterTextState extends State<_SearchFilterText> { + late final _controller = TextEditingController(text: widget.value); + @override + void didUpdateWidget(covariant _SearchFilterText oldWidget) { + super.didUpdateWidget(oldWidget); + if (_controller.text != widget.value) _controller.text = widget.value; + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) => TextField( + controller: _controller, + decoration: InputDecoration(labelText: widget.label), + onChanged: widget.onChanged, + ); +} diff --git a/lib/src/features/schedule/presentation/schedule_search_labels.dart b/lib/src/features/schedule/presentation/schedule_search_labels.dart new file mode 100644 index 00000000..3601a16e --- /dev/null +++ b/lib/src/features/schedule/presentation/schedule_search_labels.dart @@ -0,0 +1,28 @@ +import '../../../../l10n/generated/app_localizations.dart'; +import '../../../schedule/schedule_search_criteria.dart'; +import '../../../schedule/schedule_filters.dart'; + +String searchTypeLabel(AppLocalizations l, ScheduleSearchType v) => switch (v) { + ScheduleSearchType.all => l.all, + ScheduleSearchType.events => l.calendarEvents, + ScheduleSearchType.tasks => l.tasks, +}; +String searchDateLabel(AppLocalizations l, ScheduleSearchDate v) => switch (v) { + ScheduleSearchDate.any => l.searchAnyDate, + ScheduleSearchDate.today => l.today, + ScheduleSearchDate.tomorrow => l.tomorrow, + ScheduleSearchDate.thisWeek => l.searchThisWeek, + ScheduleSearchDate.custom => l.searchCustomRange, +}; +String searchCompletionLabel(AppLocalizations l, ScheduleTaskCompletion v) => + switch (v) { + ScheduleTaskCompletion.all => l.all, + ScheduleTaskCompletion.open => l.openStatus, + ScheduleTaskCompletion.completed => l.completed, + }; +String searchDueLabel(AppLocalizations l, ScheduleTaskDueState v) => + switch (v) { + ScheduleTaskDueState.any => l.searchAnyDueState, + ScheduleTaskDueState.overdue => l.overdue, + ScheduleTaskDueState.noDueDate => l.searchNoDueDate, + }; diff --git a/lib/src/features/schedule/presentation/schedule_search_result_text.dart b/lib/src/features/schedule/presentation/schedule_search_result_text.dart new file mode 100644 index 00000000..21f9b8ca --- /dev/null +++ b/lib/src/features/schedule/presentation/schedule_search_result_text.dart @@ -0,0 +1,40 @@ +import 'package:flutter/widgets.dart'; +import 'package:intl/intl.dart'; + +import '../../../l10n/l10n.dart'; +import '../../../l10n/time_format_scope.dart'; +import '../../../schedule/schedule_item.dart'; +import '../../../schedule/schedule_search_criteria.dart'; +import '../../../schedule/schedule_search_match.dart'; + +String scheduleSearchResultText( + BuildContext context, + ScheduleItem item, + ScheduleSearchCriteria criteria, + String query, +) { + final l = context.l10n; + final locale = Localizations.localeOf(context).toLanguageTag(); + String date(DateTime value) => DateFormat.yMMMd(locale).format(value); + final start = item.start; + final time = item is TaskScheduleItem + ? item.due == null + ? l.searchNoDueDate + : '${l.dueDate}: ${date(item.due!)}' + : start == null + ? l.noDate + : item.allDay + ? '${date(start)} · ${l.allDay}' + : formatClockDateTime(context, start, date(start)); + final snippet = scheduleMatchContext( + item, + query, + location: criteria.location, + person: criteria.person, + ); + return [ + time, + scheduleSearchSourceLabel(item), + if (snippet != null) snippet, + ].join(' · '); +} diff --git a/lib/src/features/schedule/presentation/schedule_workspace.dart b/lib/src/features/schedule/presentation/schedule_workspace.dart index ddb15d9f..d8908105 100644 --- a/lib/src/features/schedule/presentation/schedule_workspace.dart +++ b/lib/src/features/schedule/presentation/schedule_workspace.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'dart:io'; import 'package:flutter/material.dart'; +import 'package:yaru/yaru.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter/services.dart'; import 'package:go_router/go_router.dart'; @@ -59,6 +60,8 @@ import 'schedule_item_exporter.dart'; import 'schedule_item_selection.dart'; import 'schedule_month_view.dart'; import 'schedule_sidebar.dart'; +import 'schedule_search_filters.dart'; +import '../../../schedule/schedule_search_criteria.dart'; import 'schedule_toolbar.dart'; import 'schedule_year_view.dart'; @@ -183,7 +186,14 @@ class _ScheduleWorkspaceState extends ConsumerState { var _sidebarCollapsed = false; var _searchActive = false; var _searchQuery = ''; + bool? _sidebarBeforeSearch; + ScheduleSearchCriteria? _searchCriteria; + ScheduleSearchCriteria? _initialSearchCriteria; + List _searchSources = const []; + List _searchTaskLists = const []; + ScheduleSourceVisibility? _searchVisibility; final _searchController = TextEditingController(); + final _workspaceFocusNode = FocusNode(); var _fallbackSearchFocusRequest = 0; var _latestCanShowSidebar = false; var _latestAccounts = const []; @@ -246,6 +256,7 @@ class _ScheduleWorkspaceState extends ConsumerState { unawaited(_headerBarSearchEvents?.cancel()); unawaited(_initialTaskTargetSubscription?.cancel()); _searchController.dispose(); + _workspaceFocusNode.dispose(); super.dispose(); } @@ -270,7 +281,7 @@ class _ScheduleWorkspaceState extends ConsumerState { final settings = ref.watch(appSettingsControllerProvider); _syncModeFromSettings(settings.scheduleViewMode); final range = _range(context); - final searchHasQuery = _searchQuery.trim().isNotEmpty; + final searchActive = _searchActive; final accountsState = ref.watch(accountsStreamProvider); final accounts = accountsState.valueOrNull ?? const []; final accountsLoading = @@ -310,6 +321,15 @@ class _ScheduleWorkspaceState extends ConsumerState { taskLists: taskLists, settings: settings, ); + _searchSources = sources; + _searchTaskLists = taskLists; + _searchVisibility = visibility; + if (_searchActive && + _searchCriteria == null && + !sourcesLoading && + !taskListsLoading) { + _initializeSearch(); + } final firstWeekday = _firstWeekday(context); final revealed = _revealedNotificationTarget; final visibleSourceIds = { @@ -356,7 +376,7 @@ class _ScheduleWorkspaceState extends ConsumerState { future: _scheduleItems( repository: ref.watch(scheduleRepositoryProvider), range: range, - searchHasQuery: searchHasQuery, + searchActive: searchActive, accountIds: accountIds.toSet(), sourceIds: visibleSourceIds, taskListKeys: visibleTaskListKeys, @@ -364,7 +384,7 @@ class _ScheduleWorkspaceState extends ConsumerState { builder: (context, snapshot) { final itemsLoading = snapshot.connectionState == ConnectionState.waiting && - !snapshot.hasData; + (searchActive || !snapshot.hasData); final scheduleLoading = accountsLoading || sourcesLoading || @@ -375,39 +395,45 @@ class _ScheduleWorkspaceState extends ConsumerState { sourcesUnavailable || taskListsUnavailable || (snapshot.hasError && !snapshot.hasData); - final scopedItems = ScheduleProjection.filterByScope( - snapshot.data?.items ?? const [], - _scope, - ); + final scopedItems = searchActive + ? snapshot.data?.items ?? const [] + : ScheduleProjection.filterByScope( + snapshot.data?.items ?? const [], + _scope, + ); final items = - !searchHasQuery && _mode == ScheduleViewMode.agenda + !searchActive && _mode == ScheduleViewMode.agenda ? _agendaItems(scopedItems, range) : scopedItems; _latestItems = items; - final miniCalendarItemsFuture = ref - .watch(scheduleRepositoryProvider) - .listItems( - range: ScheduleRange.month( - _selectedDate, - firstWeekday: firstWeekday, - ), - filters: ScheduleFilters( - accountIds: accountIds.toSet(), - sourceIds: visibility.visibleCalendarSourceIds, - taskListKeys: visibleTaskListKeys, - sourceFilterActive: true, - taskListFilterActive: true, - includeCalendarEvents: - _scope != ScheduleScope.tasks, - includeTasks: _scope != ScheduleScope.events, - showCompletedTasks: true, - showNoDateTasks: false, - ), - ); - final displayRange = searchHasQuery - ? _rangeForSearchResults(items, range) + final miniCalendarItemsFuture = searchActive + ? Future>.value(const []) + : ref + .watch(scheduleRepositoryProvider) + .listItems( + range: ScheduleRange.month( + _selectedDate, + firstWeekday: firstWeekday, + ), + filters: ScheduleFilters( + accountIds: accountIds.toSet(), + sourceIds: + visibility.visibleCalendarSourceIds, + taskListKeys: visibleTaskListKeys, + sourceFilterActive: true, + taskListFilterActive: true, + includeCalendarEvents: + _scope != ScheduleScope.tasks, + includeTasks: _scope != ScheduleScope.events, + taskCompletion: ScheduleTaskCompletion.all, + showNoDateTasks: false, + ), + ); + final displayRange = searchActive + ? _searchCriteria?.range ?? + _rangeForSearchResults(items, range) : range; - final displayMode = searchHasQuery + final displayMode = searchActive ? ScheduleViewMode.agenda : _mode; _consumePendingCommand(visibleSources, accounts, sources); @@ -417,6 +443,12 @@ class _ScheduleWorkspaceState extends ConsumerState { MediaQuery.sizeOf(context).width, ); Widget buildSidebar() { + if (searchActive && _searchCriteria != null) { + return SizedBox( + width: BusyMaxSizes.sidebarWidth, + child: _searchFilterPanel(), + ); + } return SizedBox( width: BusyMaxSizes.sidebarWidth, child: FutureBuilder>( @@ -450,15 +482,33 @@ class _ScheduleWorkspaceState extends ConsumerState { horizontal: BusyMaxSpacing.md, vertical: BusyMaxSpacing.sm, ), - child: BusyMaxSearchField( - controller: _searchController, - autofocus: true, - focusRequest: _fallbackSearchFocusRequest, - hintText: MaterialLocalizations.of( - context, - ).searchFieldLabel, - onChanged: _setSearchQuery, - onClear: _clearSearchQuery, + child: Row( + children: [ + Expanded( + child: BusyMaxSearchField( + controller: _searchController, + autofocus: true, + focusRequest: _fallbackSearchFocusRequest, + hintText: MaterialLocalizations.of( + context, + ).searchFieldLabel, + onChanged: _setSearchQuery, + onClear: _clearSearchQuery, + ), + ), + if (!canShowFallbackSidebar || + _sidebarCollapsed) + YaruIconButton( + tooltip: context.l10n.searchFiltersAction, + icon: const Icon(Icons.filter_list), + onPressed: _showSearchFilters, + ), + YaruIconButton( + tooltip: context.l10n.close, + icon: const Icon(Icons.close), + onPressed: _closeSearch, + ), + ], ), ) else @@ -509,7 +559,7 @@ class _ScheduleWorkspaceState extends ConsumerState { isUnavailable: scheduleUnavailable, mode: displayMode, range: displayRange, - selectedDate: searchHasQuery + selectedDate: searchActive ? displayRange.start : _selectedDate, firstWeekday: _firstWeekday(context), @@ -566,25 +616,25 @@ class _ScheduleWorkspaceState extends ConsumerState { onPrevious: _previous, onNext: _next, onAgendaLoadMore: - !searchHasQuery && + !searchActive && _mode == ScheduleViewMode.agenda ? _loadMoreAgendaDays : null, hasMoreAgendaOverdueTasks: - !searchHasQuery && + !searchActive && _mode == ScheduleViewMode.agenda && (snapshot.data?.hasMoreOverdueTasks ?? false), hasMoreAgendaNoDateTasks: - !searchHasQuery && + !searchActive && _mode == ScheduleViewMode.agenda && (snapshot.data?.hasMoreNoDateTasks ?? false), onAgendaLoadMoreOverdue: - !searchHasQuery && + !searchActive && _mode == ScheduleViewMode.agenda ? _loadMoreAgendaOverdueTasks : null, onAgendaLoadMoreNoDate: - !searchHasQuery && + !searchActive && _mode == ScheduleViewMode.agenda ? _loadMoreAgendaNoDateTasks : null, @@ -594,7 +644,9 @@ class _ScheduleWorkspaceState extends ConsumerState { _openItem( context, item, - visibleSources, + searchActive + ? sources + : visibleSources, globalPosition: globalPosition, ), ), @@ -604,7 +656,9 @@ class _ScheduleWorkspaceState extends ConsumerState { _setChecklistItemCompleted, canCreateEvent: writableSources.isNotEmpty, canCreateTask: canCreateTask, - searchActive: searchHasQuery, + searchActive: searchActive, + searchCriteria: _searchCriteria, + searchQuery: _searchQuery, ), ), ), @@ -668,7 +722,11 @@ class _ScheduleWorkspaceState extends ConsumerState { shortcuts: _scheduleShortcuts, child: Actions( actions: {_ScheduleShortcutIntent: _ScheduleShortcutAction(this)}, - child: Focus(autofocus: true, child: child), + child: Focus( + focusNode: _workspaceFocusNode, + autofocus: true, + child: child, + ), ), ); } @@ -731,9 +789,9 @@ class _ScheduleWorkspaceState extends ConsumerState { canCreateTask: canCreateTask, searchActive: _searchActive, searchQuery: _searchQuery, - canShowSidebar: showSidebar, + canShowSidebar: showSidebar || _searchActive, sidebarVisible: sidebarVisible, - navigationVisible: _mode != ScheduleViewMode.agenda, + navigationVisible: !_searchActive && _mode != ScheduleViewMode.agenda, scheduleControlsVisible: true, backVisible: false, ); @@ -780,10 +838,10 @@ class _ScheduleWorkspaceState extends ConsumerState { switch (event) { case BusyMaxHeaderBarSearchQueryChanged(:final query): _setSearchQuery(query); - case BusyMaxHeaderBarSearchFocusChanged(): - // Native focus is presentation state. The event is still exposed by - // the route-owned bridge so callers can observe focus without making - // it part of the durable header state. + case BusyMaxHeaderBarSearchFocusChanged(:final focused): + // Leave text editing while retaining a target for workspace shortcuts + // when GTK returns keyboard focus to the Flutter view. + if (focused) _workspaceFocusNode.requestFocus(); return; case BusyMaxHeaderBarSearchCleared(): _clearSearchQuery(); @@ -831,6 +889,7 @@ class _ScheduleWorkspaceState extends ConsumerState { return; case BusyMaxHeaderBarAction.sidebarToggle: if (!_latestCanShowSidebar) { + if (_searchActive) unawaited(_showSearchFilters()); return; } setState(() => _sidebarCollapsed = !_sidebarCollapsed); @@ -860,7 +919,7 @@ class _ScheduleWorkspaceState extends ConsumerState { if (_searchActive) { _closeSearch(); } else { - setState(() => _searchActive = true); + _openSearch(); _focusSearch(); } case BusyMaxHeaderBarAction.createEvent: @@ -953,14 +1012,106 @@ class _ScheduleWorkspaceState extends ConsumerState { ); } + void _initializeSearch() { + final visibility = _searchVisibility; + if (visibility == null) return; + _initialSearchCriteria = ScheduleSearchCriteria( + type: switch (_scope) { + ScheduleScope.events => ScheduleSearchType.events, + ScheduleScope.tasks => ScheduleSearchType.tasks, + _ => ScheduleSearchType.all, + }, + referenceDate: _day(DateTime.now()), + firstWeekday: _firstWeekday(context), + sourceIds: visibility.visibleCalendarSourceIds, + taskListKeys: visibility.visibleTaskListKeys, + ); + _searchCriteria = _initialSearchCriteria; + } + + void _openSearch() { + setState(() { + _sidebarBeforeSearch = _sidebarCollapsed; + _searchActive = true; + _initializeSearch(); + }); + } + + void _focusSearchFilters() { + if (_nativeHeaderBarAvailable) unawaited(_headerBarSession.focusContent()); + } + + Widget _searchFilterPanel({VoidCallback? refresh}) => Listener( + onPointerDown: (_) => _focusSearchFilters(), + child: Focus( + onFocusChange: (focused) { + if (focused) _focusSearchFilters(); + }, + child: ScheduleSearchFilters( + value: _searchCriteria!, + accounts: _latestAccounts, + sources: _searchSources, + taskLists: _searchTaskLists, + onChanged: (value) { + setState(() => _searchCriteria = value); + refresh?.call(); + }, + onClear: () { + setState(() => _searchCriteria = _initialSearchCriteria); + refresh?.call(); + }, + ), + ), + ); + + Future _showSearchFilters() async { + if (_searchCriteria == null) return; + await showDialog( + context: context, + builder: (context) => StatefulBuilder( + builder: (context, update) => Dialog( + child: SizedBox( + width: 360, + height: MediaQuery.sizeOf(context).height * .85, + child: Column( + children: [ + Align( + alignment: AlignmentDirectional.centerEnd, + child: YaruIconButton( + tooltip: context.l10n.close, + icon: const Icon(Icons.close), + onPressed: () => Navigator.pop(context), + ), + ), + Expanded( + child: _searchFilterPanel(refresh: () => update(() {})), + ), + ], + ), + ), + ), + ), + ); + } + Future<_ScheduleItemsResult> _scheduleItems({ required ScheduleRepository repository, required ScheduleRange range, - required bool searchHasQuery, + required bool searchActive, required Set accountIds, required Set sourceIds, required Set taskListKeys, }) async { + if (searchActive) { + final criteria = _searchCriteria; + if (criteria == null) return const _ScheduleItemsResult(items: []); + return _ScheduleItemsResult( + items: await repository.listItems( + range: criteria.range ?? range, + filters: criteria.filters(_searchQuery), + ), + ); + } final currentItems = repository.listItems( range: range, filters: ScheduleFilters( @@ -972,11 +1123,11 @@ class _ScheduleWorkspaceState extends ConsumerState { taskListFilterActive: true, includeCalendarEvents: _scope != ScheduleScope.tasks, includeTasks: _scope != ScheduleScope.events, - showCompletedTasks: true, - showNoDateTasks: searchHasQuery || _mode != ScheduleViewMode.agenda, + taskCompletion: ScheduleTaskCompletion.all, + showNoDateTasks: searchActive || _mode != ScheduleViewMode.agenda, ), ); - if (searchHasQuery || _mode != ScheduleViewMode.agenda) { + if (searchActive || _mode != ScheduleViewMode.agenda) { return _ScheduleItemsResult(items: await currentItems); } @@ -988,7 +1139,7 @@ class _ScheduleWorkspaceState extends ConsumerState { taskListKeys: taskListKeys, taskListFilterActive: true, includeTasks: _scope != ScheduleScope.events, - showCompletedTasks: false, + taskCompletion: ScheduleTaskCompletion.open, ), ); final noDateTasks = repository.listNoDateTasks( @@ -998,7 +1149,7 @@ class _ScheduleWorkspaceState extends ConsumerState { taskListKeys: taskListKeys, taskListFilterActive: true, includeTasks: _scope != ScheduleScope.events, - showCompletedTasks: true, + taskCompletion: ScheduleTaskCompletion.all, ), ); final datedItems = await currentItems; @@ -1168,6 +1319,7 @@ class _ScheduleWorkspaceState extends ConsumerState { } void _goToToday() { + if (_searchActive) return; setState(() { _selectedDate = _day(DateTime.now()); if (_mode == ScheduleViewMode.agenda) { @@ -1180,6 +1332,7 @@ class _ScheduleWorkspaceState extends ConsumerState { } void _setMode(ScheduleViewMode mode, {DateTime? agendaDate}) { + if (_searchActive) return; if (_mode == mode) { if (mode == ScheduleViewMode.agenda && agendaDate != null) { _setDate(agendaDate); @@ -1238,15 +1391,18 @@ class _ScheduleWorkspaceState extends ConsumerState { void _closeSearch() { setState(() { _searchActive = false; + _sidebarCollapsed = _sidebarBeforeSearch ?? _sidebarCollapsed; + _sidebarBeforeSearch = null; + _searchCriteria = null; + _initialSearchCriteria = null; _searchQuery = ''; _searchController.clear(); }); - if (!_nativeHeaderBarAvailable) { - FocusManager.instance.primaryFocus?.unfocus(); - } + _workspaceFocusNode.requestFocus(); } void _previous() { + if (_searchActive) return; if (_mode == ScheduleViewMode.agenda) { return; } @@ -1275,6 +1431,7 @@ class _ScheduleWorkspaceState extends ConsumerState { } void _next() { + if (_searchActive) return; if (_mode == ScheduleViewMode.agenda) { return; } @@ -1306,7 +1463,7 @@ class _ScheduleWorkspaceState extends ConsumerState { } return switch (command) { _ScheduleShortcut.search => true, - _ScheduleShortcut.sidebar => _latestCanShowSidebar, + _ScheduleShortcut.sidebar => _latestCanShowSidebar || _searchActive, _ScheduleShortcut.dismissSearch => _searchActive, _ScheduleShortcut.newEvent => _canHandleScheduleShortcut() && _latestWritableSources.isNotEmpty, @@ -1327,11 +1484,11 @@ class _ScheduleWorkspaceState extends ConsumerState { switch (command) { case _ScheduleShortcut.search: if (!_searchActive) { - setState(() => _searchActive = true); + _openSearch(); } _focusSearch(); case _ScheduleShortcut.sidebar: - setState(() => _sidebarCollapsed = !_sidebarCollapsed); + _handleHeaderBarAction(BusyMaxHeaderBarAction.sidebarToggle); case _ScheduleShortcut.dismissSearch: _closeSearch(); case _ScheduleShortcut.previous: @@ -2516,6 +2673,10 @@ class _ScheduleWorkspaceState extends ConsumerState { _revealedNotificationTarget = command; _scope = ScheduleScope.all; _searchActive = false; + _sidebarCollapsed = _sidebarBeforeSearch ?? _sidebarCollapsed; + _sidebarBeforeSearch = null; + _searchCriteria = null; + _initialSearchCriteria = null; _searchQuery = ''; _searchController.clear(); }); @@ -2669,6 +2830,8 @@ class _ScheduleBody extends StatelessWidget { required this.canCreateEvent, required this.canCreateTask, required this.searchActive, + this.searchCriteria, + this.searchQuery = '', }); final bool isLoading; @@ -2715,6 +2878,8 @@ class _ScheduleBody extends StatelessWidget { final bool canCreateEvent; final bool canCreateTask; final bool searchActive; + final ScheduleSearchCriteria? searchCriteria; + final String searchQuery; @override Widget build(BuildContext context) { @@ -2724,7 +2889,7 @@ class _ScheduleBody extends StatelessWidget { if (isLoading) { return const ScheduleLoadingState(); } - if (!hasAnySources) { + if (!hasAnySources && !searchActive) { return ScheduleNoSourcesState( hasAccounts: hasAccounts, onOpenSettings: onOpenSettings, @@ -2732,6 +2897,9 @@ class _ScheduleBody extends StatelessWidget { ); } if (items.isEmpty && searchActive) { + if (searchCriteria?.hasSources == false) { + return Center(child: Text(context.l10n.searchNoSources)); + } return const ScheduleSearchEmptyState(); } if (items.isEmpty && mode == ScheduleViewMode.agenda) { @@ -2800,6 +2968,8 @@ class _ScheduleBody extends StatelessWidget { ), ), ScheduleViewMode.agenda => ScheduleAgendaView( + searchCriteria: searchCriteria, + searchQuery: searchQuery, range: range, items: items, hasMoreOverdueTasks: hasMoreAgendaOverdueTasks, diff --git a/lib/src/features/tray/data/tray_presentation_service.dart b/lib/src/features/tray/data/tray_presentation_service.dart index 83c32dda..414274c3 100644 --- a/lib/src/features/tray/data/tray_presentation_service.dart +++ b/lib/src/features/tray/data/tray_presentation_service.dart @@ -71,7 +71,7 @@ final class BusyMaxTrayPresentationService { taskListKeys: visibility.visibleTaskListKeys, sourceFilterActive: true, taskListFilterActive: true, - showCompletedTasks: false, + taskCompletion: ScheduleTaskCompletion.open, showNoDateTasks: false, ), ); diff --git a/lib/src/platform/linux_header_bar_service.dart b/lib/src/platform/linux_header_bar_service.dart index cd909cb3..acf1dbd7 100644 --- a/lib/src/platform/linux_header_bar_service.dart +++ b/lib/src/platform/linux_header_bar_service.dart @@ -781,6 +781,20 @@ class LinuxHeaderBarService { return false; } + Future _focusContent(LinuxHeaderBarSession session) async { + if (_disposed || !_available || !_isCurrentSession(session)) { + return false; + } + try { + return await _channel.invokeMethod('focusContent') ?? false; + } on MissingPluginException { + _available = false; + } on PlatformException { + _available = false; + } + return false; + } + BusyMaxHeaderBarSearchEvent? _searchEventForCall(MethodCall call) { return switch ((call.method, call.arguments)) { ('searchQueryChanged', final String query) => @@ -903,6 +917,18 @@ class LinuxHeaderBarSession { return _service._focusSearch(this); } + /// Returns GTK keyboard focus to the Flutter filter controls. + Future focusContent() async { + if (_disposed) { + return false; + } + await initialize(); + if (!isCurrent) { + return false; + } + return _service._focusContent(this); + } + Future setOnboardingControls({ required bool visible, required bool canGoBack, diff --git a/lib/src/schedule/schedule_filters.dart b/lib/src/schedule/schedule_filters.dart index 415399b6..7d9deb94 100644 --- a/lib/src/schedule/schedule_filters.dart +++ b/lib/src/schedule/schedule_filters.dart @@ -1,3 +1,7 @@ +enum ScheduleTaskCompletion { open, all, completed } + +enum ScheduleTaskDueState { any, overdue, noDueDate } + class ScheduleTaskListKey { const ScheduleTaskListKey({ required this.accountId, @@ -28,7 +32,13 @@ class ScheduleFilters { this.includeCalendarEvents = true, this.includeTasks = true, this.query = '', - this.showCompletedTasks = false, + this.taskCompletion = ScheduleTaskCompletion.open, + this.ignoreDateRange = false, + this.useTaskDueDate = false, + this.taskDueState = ScheduleTaskDueState.any, + this.referenceDate, + this.person = '', + this.location = '', this.showNoDateTasks = true, }); @@ -40,6 +50,16 @@ class ScheduleFilters { final bool includeCalendarEvents; final bool includeTasks; final String query; - final bool showCompletedTasks; + final ScheduleTaskCompletion taskCompletion; + + /// Query every locally projected record without extending provider history. + final bool ignoreDateRange; + + /// Search dates describe due dates; normal Schedule dates describe scheduling. + final bool useTaskDueDate; + final ScheduleTaskDueState taskDueState; + final DateTime? referenceDate; + final String person; + final String location; final bool showNoDateTasks; } diff --git a/lib/src/schedule/schedule_item.dart b/lib/src/schedule/schedule_item.dart index 02157669..a7346285 100644 --- a/lib/src/schedule/schedule_item.dart +++ b/lib/src/schedule/schedule_item.dart @@ -170,6 +170,7 @@ class TaskScheduleItem implements ScheduleItem { required this.completed, required this.allDay, this.start, + this.due, this.end, this.notes, this.location, @@ -204,6 +205,9 @@ class TaskScheduleItem implements ScheduleItem { @override final bool allDay; final bool completed; + + /// Actual provider due value, independent of the scheduling start. + final DateTime? due; final String? notes; final String? location; final GeographicPoint? locationPoint; diff --git a/lib/src/schedule/schedule_repository.dart b/lib/src/schedule/schedule_repository.dart index 5a0a7ec3..807d9287 100644 --- a/lib/src/schedule/schedule_repository.dart +++ b/lib/src/schedule/schedule_repository.dart @@ -19,6 +19,8 @@ import 'schedule_item.dart'; import 'schedule_projection.dart'; import 'schedule_range.dart'; import 'schedule_sorting.dart'; +import 'schedule_search_match.dart'; +export 'schedule_search_match.dart' show matchesScheduleQuery; class ScheduleRepository { const ScheduleRepository( @@ -129,21 +131,21 @@ class ScheduleRepository { required ScheduleRange range, ScheduleFilters filters = const ScheduleFilters(), }) async { - if (filters.includeCalendarEvents) { + if (filters.includeCalendarEvents && !filters.ignoreDateRange) { await _ensureProjectionCoverage?.call(range); } final context = await _accountContext(filters); if (context == null) { return const []; } - final searching = filters.query.trim().isNotEmpty; + final ignoreDateRange = filters.ignoreDateRange; final items = [ if (filters.includeCalendarEvents) ...await _calendarItems( range, filters, - searching, + ignoreDateRange, context.accountIds, context.providers, context.accountDisplayNames, @@ -153,18 +155,16 @@ class ScheduleRepository { ...await _taskItems( range, filters, - searching, + ignoreDateRange, context.accountIds, context.providers, context.accountDisplayNames, context.accountEmails, ), ]; - final filtered = filters.query.trim().isEmpty - ? items - : items - .where((item) => matchesScheduleQuery(item, filters.query)) - .toList(); + final filtered = items + .where((item) => matchesScheduleFilters(item, filters, range: range)) + .toList(); filtered.sort(compareScheduleItems); return filtered; } @@ -178,7 +178,7 @@ class ScheduleRepository { Future> listAllTasks({ ScheduleFilters filters = const ScheduleFilters( includeCalendarEvents: false, - showCompletedTasks: true, + taskCompletion: ScheduleTaskCompletion.all, showNoDateTasks: true, ), }) async { @@ -186,8 +186,7 @@ class ScheduleRepository { final context = await _accountContext(filters); if (context == null) return const []; - // `_taskItems` ignores this range when `searching` is true. Here that flag - // deliberately requests the same unbounded query used by global search. + // The Tasks workspace explicitly requests every local task. final items = await _taskItems( ScheduleRange.day(DateTime.now()), filters, @@ -394,7 +393,7 @@ class ScheduleRepository { Future> _calendarItems( ScheduleRange range, ScheduleFilters filters, - bool searching, + bool ignoreDateRange, List accountIds, Map providers, Map accountDisplayNames, @@ -455,7 +454,7 @@ class ScheduleRepository { final davCapabilities = collection == null ? null : collectionCapabilitiesFromStored(collection); - if (!searching && !_intersects(range, start, end)) { + if (!ignoreDateRange && !_intersects(range, start, end)) { continue; } items.add( @@ -548,7 +547,7 @@ class ScheduleRepository { Future> _taskItems( ScheduleRange range, ScheduleFilters filters, - bool searching, + bool ignoreDateRange, List accountIds, Map providers, Map accountDisplayNames, @@ -595,10 +594,15 @@ class ScheduleRepository { if (filters.taskListFilterActive) { query.where(_taskListFilter(filters.taskListKeys)); } - if (!filters.showCompletedTasks) { - query.where(_taskIncomplete()); + switch (filters.taskCompletion) { + case ScheduleTaskCompletion.open: + query.where(_taskIncomplete()); + case ScheduleTaskCompletion.completed: + query.where(_database.tasks.status.equals('completed')); + case ScheduleTaskCompletion.all: + break; } - if (!searching) { + if (!ignoreDateRange && !filters.useTaskDueDate) { final inRange = _taskScheduledInRange(range) | _database.tasks.davCollectionId.isNotNull(); @@ -615,15 +619,20 @@ class ScheduleRepository { accountEmails, hierarchy, ); - if (!filters.showCompletedTasks && item.completed) { + if (!matchesTaskCompletion(item, filters.taskCompletion)) { continue; } final start = item.start; final end = item.end; - if (start == null && !filters.showNoDateTasks) { + if (!filters.useTaskDueDate && + start == null && + !filters.showNoDateTasks) { continue; } - if (start != null && !searching && !_intersects(range, start, end)) { + if (!filters.useTaskDueDate && + start != null && + !ignoreDateRange && + !_intersects(range, start, end)) { continue; } items.add(item); @@ -696,8 +705,13 @@ class ScheduleRepository { if (filters.taskListFilterActive) { query.where(_taskListFilter(filters.taskListKeys)); } - if (!filters.showCompletedTasks) { - query.where(_taskIncomplete()); + switch (filters.taskCompletion) { + case ScheduleTaskCompletion.open: + query.where(_taskIncomplete()); + case ScheduleTaskCompletion.completed: + query.where(_database.tasks.status.equals('completed')); + case ScheduleTaskCompletion.all: + break; } query.orderBy([ OrderingTerm.asc(_database.tasks.dueUtc), @@ -720,7 +734,7 @@ class ScheduleRepository { context.accountEmails, hierarchy, ); - if (!filters.showCompletedTasks && item.completed) { + if (!matchesTaskCompletion(item, filters.taskCompletion)) { continue; } if (!itemFilter(item)) { @@ -768,6 +782,7 @@ class ScheduleRepository { allDay: _taskAllDay(task, provider), start: start, end: _taskEnd(task, provider), + due: _taskDue(task, provider), notes: task.notes ?? task.bodyContent, location: task.taskLocation, locationPoint: GeographicPoint.tryParse( @@ -1031,35 +1046,26 @@ String _dateKey(DateTime value) { return (contentType: contentType, html: html); } -bool matchesScheduleQuery(ScheduleItem item, String query) { - final terms = query - .trim() - .toLowerCase() - .split(RegExp(r'\s+')) - .where((term) => term.isNotEmpty) - .toList(); - if (terms.isEmpty) { - return true; +DateTime? _taskDue(Task task, BusyProvider provider) { + if (provider == BusyProvider.microsoft) { + return providerDateTimeAsLocal( + task.microsoftDueDateTime, + task.microsoftDueTimeZone, + ); + } + if (_isDavProvider(provider)) { + final due = _davTaskScheduleTemporal(task, dueOnly: true); + if (due != null) return _parseDavTaskTemporal(due); + // DAV's legacy dueUtc projection can contain DTSTART when DUE is absent. + if (_jsonMapFromString( + task.providerMetadataJson, + )?.containsKey('nativeStart') == + true) { + return null; + } + return _parseDateTime(task.dueUtc); } - final fields = [ - item.title, - item.sourceName ?? '', - item.provider.displayName, - item.accountDisplayName ?? '', - item.accountEmail ?? '', - if (item is CalendarScheduleItem) ...[ - item.location ?? '', - item.description ?? '', - ...item.categories, - ], - if (item is TaskScheduleItem) ...[ - item.notes ?? '', - item.parentTitle ?? '', - ...item.categories, - ...item.checklistItems.map((subtask) => subtask.title), - ], - ].map((value) => value.toLowerCase()).toList(); - return terms.every((term) => fields.any((field) => field.contains(term))); + return _parseDate(task.dueUtc); } DateTime? _taskStart(Task task, BusyProvider provider) { @@ -1107,7 +1113,7 @@ bool _isDateOnly(String value) => !value.contains('T'); bool _isDavProvider(BusyProvider provider) => provider == BusyProvider.appleICloud || provider == BusyProvider.nextcloud; -_DavTaskTemporal? _davTaskScheduleTemporal(Task task) { +_DavTaskTemporal? _davTaskScheduleTemporal(Task task, {bool dueOnly = false}) { final source = task.providerMetadataJson; if (source == null || source.isEmpty) return null; try { @@ -1117,6 +1123,7 @@ _DavTaskTemporal? _davTaskScheduleTemporal(Task task) { (nativeKey: 'nativeStart', utcKey: 'startUtc'), (nativeKey: 'nativeDue', utcKey: 'dueUtc'), ]) { + if (dueOnly && entry.nativeKey != 'nativeDue') continue; final value = decoded[entry.nativeKey]; if (value is! Map) continue; final raw = value['raw']; diff --git a/lib/src/schedule/schedule_search_criteria.dart b/lib/src/schedule/schedule_search_criteria.dart new file mode 100644 index 00000000..f12cb07f --- /dev/null +++ b/lib/src/schedule/schedule_search_criteria.dart @@ -0,0 +1,139 @@ +import 'package:collection/collection.dart'; + +import 'schedule_filters.dart'; +import 'schedule_range.dart'; + +enum ScheduleSearchType { all, events, tasks } + +enum ScheduleSearchDate { any, today, tomorrow, thisWeek, custom } + +/// One immutable snapshot of a temporary search session. Set contents have +/// value equality, so providers and caches invalidate for every criterion. +class ScheduleSearchCriteria { + ScheduleSearchCriteria({ + this.type = ScheduleSearchType.all, + this.date = ScheduleSearchDate.any, + this.taskCompletion = ScheduleTaskCompletion.all, + this.taskDueState = ScheduleTaskDueState.any, + this.person = '', + this.location = '', + this.customStart, + this.customEnd, + required this.referenceDate, + required this.firstWeekday, + required Set sourceIds, + required Set taskListKeys, + }) : sourceIds = Set.unmodifiable(sourceIds), + taskListKeys = Set.unmodifiable(taskListKeys); + + final ScheduleSearchType type; + final ScheduleSearchDate date; + final ScheduleTaskCompletion taskCompletion; + final ScheduleTaskDueState taskDueState; + final String person; + final String location; + final DateTime? customStart; + final DateTime? customEnd; + final DateTime referenceDate; + final int firstWeekday; + final Set sourceIds; + final Set taskListKeys; + bool get includesEvents => type != ScheduleSearchType.tasks; + bool get includesTasks => type != ScheduleSearchType.events; + bool get hasSources => + (includesEvents && sourceIds.isNotEmpty) || + (includesTasks && taskListKeys.isNotEmpty); + + ScheduleRange? get range => switch (date) { + ScheduleSearchDate.any => null, + ScheduleSearchDate.today => ScheduleRange.day(referenceDate), + ScheduleSearchDate.tomorrow => ScheduleRange.day( + DateTime(referenceDate.year, referenceDate.month, referenceDate.day + 1), + ), + ScheduleSearchDate.thisWeek => ScheduleRange.week( + referenceDate, + firstWeekday: firstWeekday, + ), + ScheduleSearchDate.custom => ScheduleRange( + start: customStart ?? ScheduleRange.day(referenceDate).start, + end: DateTime( + (customEnd ?? referenceDate).year, + (customEnd ?? referenceDate).month, + (customEnd ?? referenceDate).day + 1, + ), + ), + }; + + ScheduleFilters filters(String query) => ScheduleFilters( + query: query, + sourceIds: sourceIds, + taskListKeys: taskListKeys, + sourceFilterActive: true, + taskListFilterActive: true, + includeCalendarEvents: includesEvents, + includeTasks: includesTasks, + ignoreDateRange: date == ScheduleSearchDate.any, + useTaskDueDate: true, + taskCompletion: taskCompletion, + taskDueState: taskDueState, + referenceDate: referenceDate, + person: includesEvents ? person : '', + location: location, + ); + + ScheduleSearchCriteria copyWith({ + ScheduleSearchType? type, + ScheduleSearchDate? date, + ScheduleTaskCompletion? taskCompletion, + ScheduleTaskDueState? taskDueState, + String? person, + String? location, + DateTime? customStart, + DateTime? customEnd, + Set? sourceIds, + Set? taskListKeys, + }) => ScheduleSearchCriteria( + type: type ?? this.type, + date: date ?? this.date, + taskCompletion: taskCompletion ?? this.taskCompletion, + taskDueState: taskDueState ?? this.taskDueState, + person: (type ?? this.type) == ScheduleSearchType.tasks + ? '' + : person ?? this.person, + location: location ?? this.location, + customStart: customStart ?? this.customStart, + customEnd: customEnd ?? this.customEnd, + referenceDate: referenceDate, + firstWeekday: firstWeekday, + sourceIds: sourceIds ?? this.sourceIds, + taskListKeys: taskListKeys ?? this.taskListKeys, + ); + + Object get _values => ( + type, + date, + taskCompletion, + taskDueState, + person, + location, + customStart, + customEnd, + referenceDate, + firstWeekday, + ); + @override + bool operator ==(Object other) => + other is ScheduleSearchCriteria && + _values == other._values && + const SetEquality().equals(sourceIds, other.sourceIds) && + const SetEquality().equals( + taskListKeys, + other.taskListKeys, + ); + @override + int get hashCode => Object.hash( + _values, + const SetEquality().hash(sourceIds), + const SetEquality().hash(taskListKeys), + ); +} diff --git a/lib/src/schedule/schedule_search_match.dart b/lib/src/schedule/schedule_search_match.dart new file mode 100644 index 00000000..528edc4b --- /dev/null +++ b/lib/src/schedule/schedule_search_match.dart @@ -0,0 +1,209 @@ +import '../providers/busy_provider.dart'; +import '../calendar_providers/calendar_description.dart'; +import 'schedule_filters.dart'; +import 'schedule_item.dart'; +import 'schedule_range.dart'; + +List _terms(String query) => query + .trim() + .toLowerCase() + .split(RegExp(r'\s+')) + .where((term) => term.isNotEmpty) + .toList(); + +/// Only identity fields are searchable; response and other provider metadata +/// deliberately do not become part of a person's identity. +Iterable scheduleEventPeople(CalendarScheduleItem item) sync* { + for (final person in [ + if (item.organizer != null) item.organizer!, + ...item.attendees, + ]) { + for (final key in const [ + 'displayName', + 'name', + 'email', + 'address', + 'cn', + 'uri', + ]) { + final value = person[key]; + if (value is String && value.trim().isNotEmpty) yield value; + } + final nested = person['emailAddress']; + if (nested is Map) { + for (final key in const ['name', 'address']) { + final value = nested[key]; + if (value is String && value.trim().isNotEmpty) yield value; + } + } + } +} + +String? scheduleItemLocation(ScheduleItem item) => switch (item) { + CalendarScheduleItem() => item.location, + TaskScheduleItem() => item.location, +}; + +String _plainDescription(ScheduleItem item) => switch (item) { + CalendarScheduleItem() => htmlCalendarDescriptionToPlainText( + item.description ?? item.descriptionHtml ?? '', + ), + TaskScheduleItem() => htmlCalendarDescriptionToPlainText(item.notes ?? ''), +}; + +Iterable _contextFields(ScheduleItem item) sync* { + yield scheduleItemLocation(item) ?? ''; + if (item is CalendarScheduleItem) yield* scheduleEventPeople(item); + yield _plainDescription(item); + yield* item.categories; + if (item is TaskScheduleItem) { + yield item.parentTitle ?? ''; + yield* item.checklistItems.map((entry) => entry.title); + } +} + +bool matchesScheduleQuery(ScheduleItem item, String query) { + final fields = [ + item.title, + item.sourceName ?? '', + item.provider.displayName, + item.accountDisplayName ?? '', + item.accountEmail ?? '', + ..._contextFields(item), + ].map((value) => value.toLowerCase()).toList(); + return _terms( + query, + ).every((term) => fields.any((field) => field.contains(term))); +} + +bool matchesTaskCompletion( + TaskScheduleItem item, + ScheduleTaskCompletion completion, +) => switch (completion) { + ScheduleTaskCompletion.open => !item.completed, + ScheduleTaskCompletion.all => true, + ScheduleTaskCompletion.completed => item.completed, +}; + +bool matchesScheduleFilters( + ScheduleItem item, + ScheduleFilters filters, { + required ScheduleRange range, +}) { + if (filters.accountIds.isNotEmpty && + !filters.accountIds.contains(item.accountId)) { + return false; + } + if (item is CalendarScheduleItem) { + if (!filters.includeCalendarEvents || + (filters.sourceFilterActive && + !filters.sourceIds.contains(item.sourceId))) { + return false; + } + if (!filters.ignoreDateRange) { + final start = item.start; + if (start == null || + !start.isBefore(range.end) || + !(item.end ?? start.add(const Duration(minutes: 1))).isAfter( + range.start, + )) { + return false; + } + } + final people = scheduleEventPeople( + item, + ).map((s) => s.toLowerCase()).toList(); + if (!_terms( + filters.person, + ).every((term) => people.any((s) => s.contains(term)))) { + return false; + } + } + if (item is TaskScheduleItem) { + if (!filters.includeTasks || + (filters.taskListFilterActive && + !filters.taskListKeys.contains( + ScheduleTaskListKey( + accountId: item.accountId, + taskListId: item.sourceId, + ), + ))) { + return false; + } + if (!matchesTaskCompletion(item, filters.taskCompletion)) return false; + // Person applies to events. A Tasks-only scope ignores the hidden control. + if (filters.includeCalendarEvents && filters.person.trim().isNotEmpty) { + return false; + } + if (filters.useTaskDueDate && + !filters.ignoreDateRange && + (item.due == null || !range.contains(item.due!))) { + return false; + } + final now = filters.referenceDate ?? DateTime.now(); + final today = DateTime(now.year, now.month, now.day); + switch (filters.taskDueState) { + case ScheduleTaskDueState.any: + break; + case ScheduleTaskDueState.overdue: + if (item.completed || item.due == null || !item.due!.isBefore(today)) { + return false; + } + case ScheduleTaskDueState.noDueDate: + if (item.due != null) return false; + } + } + final location = (scheduleItemLocation(item) ?? '').toLowerCase(); + return location.contains(filters.location.trim().toLowerCase()) && + matchesScheduleQuery(item, filters.query); +} + +/// A single bounded context snippet shared by the three presentations. +String? scheduleMatchContext( + ScheduleItem item, + String query, { + String location = '', + String person = '', +}) { + final terms = _terms(query); + if (location.trim().isNotEmpty && + scheduleItemLocation(item)?.isNotEmpty == true) { + return _snippet(scheduleItemLocation(item)!, _terms(location)); + } + if (person.trim().isNotEmpty && item is CalendarScheduleItem) { + for (final identity in scheduleEventPeople(item)) { + if (_terms(person).any(identity.toLowerCase().contains)) { + return _snippet(identity, _terms(person)); + } + } + } + if (terms.isEmpty || terms.every(item.title.toLowerCase().contains)) { + return null; + } + for (final field in _contextFields(item)) { + if (terms.any(field.toLowerCase().contains)) return _snippet(field, terms); + } + return null; +} + +String _snippet(String field, List terms) { + final text = field.replaceAll(RegExp(r'\s+'), ' ').trim(); + if (text.length <= 140) return text; + final matches = + terms.map(text.toLowerCase().indexOf).where((i) => i >= 0).toList() + ..sort(); + final start = matches.isEmpty + ? 0 + : (matches.first - 35).clamp(0, text.length - 140); + return '${start > 0 ? '…' : ''}${text.substring(start, start + 140)}…'; +} + +String scheduleSearchSourceLabel(ScheduleItem item) { + final account = item.accountEmail?.trim().isNotEmpty == true + ? item.accountEmail + : item.accountDisplayName; + return [ + item.sourceName ?? item.provider.displayName, + account ?? item.accountId, + ].join(' · '); +} diff --git a/lib/src/ui/windows/windows_schedule_page.dart b/lib/src/ui/windows/windows_schedule_page.dart index 0149555c..f7d84b06 100644 --- a/lib/src/ui/windows/windows_schedule_page.dart +++ b/lib/src/ui/windows/windows_schedule_page.dart @@ -39,6 +39,9 @@ import 'windows_calendar_activation_flows.dart'; import 'windows_event_editor_dialog.dart'; import 'windows_guest_update_dialog.dart'; import 'windows_schedule_source_pane.dart'; +import 'windows_schedule_search_pane.dart'; +import '../../schedule/schedule_search_criteria.dart'; +import '../../features/schedule/presentation/schedule_search_result_text.dart'; import 'windows_task_details_dialog.dart'; import 'windows_task_editor_dialog.dart'; @@ -57,13 +60,18 @@ class _WindowsSchedulePageState extends ConsumerState { final _searchFocusNode = FocusNode(); var _selectedDate = DateTime.now(); var _query = ''; + bool _searchActive = false; + bool? _sourcePaneBeforeSearch; + ScheduleSearchCriteria? _searchCriteria; + ScheduleSearchCriteria? _initialSearchCriteria; + ScheduleSourceVisibility? _latestVisibility; late ScheduleViewMode _mode; var _agendaDays = 30; var _agendaTaskLimit = 100; var _refreshRevision = 0; String? _taskListsKey; Future>? _taskListsFuture; - String? _itemsKey; + Object? _itemsKey; Future>? _itemsFuture; var _sourcePaneCollapsed = false; Timer? _searchDebounce; @@ -77,6 +85,7 @@ class _WindowsSchedulePageState extends ConsumerState { void initState() { super.initState(); _mode = ref.read(appSettingsControllerProvider).scheduleViewMode; + _searchFocusNode.addListener(_searchFocusChanged); ref.listenManual(scheduleDataRevisionProvider, (previous, next) { if (!next.hasValue || !mounted) return; _reload(); @@ -107,6 +116,10 @@ class _WindowsSchedulePageState extends ConsumerState { _searchDebounce?.cancel(); _searchController.clear(); _query = ''; + _searchActive = false; + _searchCriteria = null; + _initialSearchCriteria = null; + _searchFocusNode.unfocus(); _openDay(command.date ?? DateTime.now()); _pendingCommand = command; unawaited(_revealPendingCommand()); @@ -119,6 +132,7 @@ class _WindowsSchedulePageState extends ConsumerState { void dispose() { _searchDebounce?.cancel(); _searchController.dispose(); + _searchFocusNode.removeListener(_searchFocusChanged); _searchFocusNode.dispose(); super.dispose(); } @@ -136,7 +150,7 @@ class _WindowsSchedulePageState extends ConsumerState { final repository = ref.read(scheduleRepositoryProvider); final filters = ScheduleFilters( accountIds: {if (command.accountId != null) command.accountId!}, - showCompletedTasks: true, + taskCompletion: ScheduleTaskCompletion.all, showNoDateTasks: true, ); final items = command.kind == ScheduleWorkspaceCommandKind.openTask @@ -258,6 +272,8 @@ class _WindowsSchedulePageState extends ConsumerState { required List taskLists, required ScheduleSourceVisibility visibility, }) { + _latestVisibility = visibility; + if (_searchActive && _searchCriteria == null) _initializeSearch(); _creationCalendar = writableCalendarSources(sources) .where( (source) => visibility.visibleCalendarSourceIds.contains(source.id), @@ -273,18 +289,20 @@ class _WindowsSchedulePageState extends ConsumerState { constraints.maxWidth, ); final showSourcePane = canShowSourcePane && !_sourcePaneCollapsed; - final sourcePane = WindowsScheduleSourcePane( - selectedDate: _selectedDate, - accounts: accounts, - calendarSources: sources, - taskLists: taskLists, - visibleCalendarSourceIds: visibility.visibleCalendarSourceIds, - visibleTaskListKeys: visibility.visibleTaskListKeys, - onDateSelected: _openDay, - onCalendarVisibilityChanged: _setCalendarVisible, - onTaskListVisibilityChanged: _setTaskListVisible, - onSourcesChanged: _reload, - ); + final sourcePane = _searchActive && _searchCriteria != null + ? _searchPane(accounts, sources, taskLists) + : WindowsScheduleSourcePane( + selectedDate: _selectedDate, + accounts: accounts, + calendarSources: sources, + taskLists: taskLists, + visibleCalendarSourceIds: visibility.visibleCalendarSourceIds, + visibleTaskListKeys: visibility.visibleTaskListKeys, + onDateSelected: _openDay, + onCalendarVisibilityChanged: _setCalendarVisible, + onTaskListVisibilityChanged: _setTaskListVisible, + onSourcesChanged: _reload, + ); return CallbackShortcuts( bindings: { BusyMaxShortcutActivators.search: _focusSearch, @@ -356,202 +374,239 @@ class _WindowsSchedulePageState extends ConsumerState { () => _setMode(ScheduleViewMode.agenda), ), }, - child: ScaffoldPage( - header: PageHeader( - title: Text(_periodTitle(l10n, locale, range)), - commandBar: CommandBar( - compactBreakpointWidth: BusyMaxBreakpoints.compact, - primaryItems: [ - if (!showSourcePane) + child: Focus( + autofocus: true, + child: ScaffoldPage( + header: PageHeader( + title: Text(_periodTitle(l10n, locale, range)), + commandBar: CommandBar( + compactBreakpointWidth: BusyMaxBreakpoints.compact, + primaryItems: [ + if (!showSourcePane) + CommandBarButton( + icon: Icon(windowsBusyMaxGlyph(BusyMaxGlyph.more)), + label: Text( + _searchActive + ? l10n.searchFiltersAction + : l10n.showSidebar, + ), + onPressed: () { + if (canShowSourcePane) { + setState(() => _sourcePaneCollapsed = false); + } else { + unawaited( + _showSourcesDialog( + accounts: accounts, + sources: sources, + taskLists: taskLists, + visibility: visibility, + ), + ); + } + }, + ), + if (!_searchActive) + CommandBarButton( + icon: Icon(windowsBusyMaxGlyph(BusyMaxGlyph.today)), + label: Text(l10n.today), + onPressed: () => _selectDate(DateTime.now()), + ), + if (!_searchActive && _mode != ScheduleViewMode.agenda) ...[ + CommandBarButton( + icon: Icon(windowsBusyMaxGlyph(BusyMaxGlyph.previous)), + tooltip: l10n.shortcutPreviousPeriodDescription, + onPressed: () => _movePeriod(-1), + ), + CommandBarButton( + icon: Icon(windowsBusyMaxGlyph(BusyMaxGlyph.next)), + tooltip: l10n.shortcutNextPeriodDescription, + onPressed: () => _movePeriod(1), + ), + ], CommandBarButton( - icon: Icon(windowsBusyMaxGlyph(BusyMaxGlyph.more)), - label: Text(l10n.showSidebar), - onPressed: () { - if (canShowSourcePane) { - setState(() => _sourcePaneCollapsed = false); - } else { - unawaited( - _showSourcesDialog( - accounts: accounts, - sources: sources, - taskLists: taskLists, - visibility: visibility, - ), - ); - } - }, + icon: Icon(windowsBusyMaxGlyph(BusyMaxGlyph.add)), + label: Text(l10n.newEvent), + onPressed: () => unawaited(_createEvent()), ), - CommandBarButton( - icon: Icon(windowsBusyMaxGlyph(BusyMaxGlyph.today)), - label: Text(l10n.today), - onPressed: () => _selectDate(DateTime.now()), - ), - if (_mode != ScheduleViewMode.agenda) ...[ + ], + secondaryItems: [ CommandBarButton( - icon: Icon(windowsBusyMaxGlyph(BusyMaxGlyph.previous)), - tooltip: l10n.shortcutPreviousPeriodDescription, - onPressed: () => _movePeriod(-1), + icon: Icon(windowsBusyMaxGlyph(BusyMaxGlyph.task)), + label: Text(l10n.newTask), + onPressed: () => unawaited(_createTask()), ), CommandBarButton( - icon: Icon(windowsBusyMaxGlyph(BusyMaxGlyph.next)), - tooltip: l10n.shortcutNextPeriodDescription, - onPressed: () => _movePeriod(1), + icon: Icon(windowsBusyMaxGlyph(BusyMaxGlyph.refresh)), + label: Text(l10n.refresh), + onPressed: _reload, ), - ], - CommandBarButton( - icon: Icon(windowsBusyMaxGlyph(BusyMaxGlyph.add)), - label: Text(l10n.newEvent), - onPressed: () => unawaited(_createEvent()), - ), - ], - secondaryItems: [ - CommandBarButton( - icon: Icon(windowsBusyMaxGlyph(BusyMaxGlyph.task)), - label: Text(l10n.newTask), - onPressed: () => unawaited(_createTask()), - ), - CommandBarButton( - icon: Icon(windowsBusyMaxGlyph(BusyMaxGlyph.refresh)), - label: Text(l10n.refresh), - onPressed: _reload, - ), - CommandBarButton( - icon: Icon(windowsBusyMaxGlyph(BusyMaxGlyph.calendar)), - label: Text(l10n.importIcsFile), - onPressed: () => unawaited( - showWindowsIcsImportFlow( - context, - ref, - ).then((_) => _reload()), + CommandBarButton( + icon: Icon(windowsBusyMaxGlyph(BusyMaxGlyph.calendar)), + label: Text(l10n.importIcsFile), + onPressed: () => unawaited( + showWindowsIcsImportFlow( + context, + ref, + ).then((_) => _reload()), + ), ), - ), - ], + ], + ), ), - ), - content: Row( - children: [ - if (showSourcePane) ...[ - SizedBox( - width: BusyMaxDimensions.sourcePaneWidth, - child: sourcePane, - ), - const Divider(direction: Axis.vertical), - ], - Expanded( - child: Column( - children: [ - Padding( - padding: const EdgeInsetsDirectional.fromSTEB( - 20, - 0, - 20, - 12, - ), - child: Row( - children: [ - Expanded( - child: TextBox( - controller: _searchController, - focusNode: _searchFocusNode, - placeholder: l10n.windowsSearch, - prefix: Padding( - padding: const EdgeInsetsDirectional.only( - start: 10, - ), - child: Icon( - windowsBusyMaxGlyph(BusyMaxGlyph.search), + content: Row( + children: [ + if (showSourcePane) ...[ + SizedBox( + width: BusyMaxDimensions.sourcePaneWidth, + child: sourcePane, + ), + const Divider(direction: Axis.vertical), + ], + Expanded( + child: Column( + children: [ + Padding( + padding: const EdgeInsetsDirectional.fromSTEB( + 20, + 0, + 20, + 12, + ), + child: Row( + children: [ + Expanded( + child: TextBox( + controller: _searchController, + focusNode: _searchFocusNode, + placeholder: l10n.windowsSearch, + suffix: _searchActive + ? Tooltip( + message: l10n.searchClearText, + child: IconButton( + icon: const Icon(FluentIcons.clear), + onPressed: () { + _searchDebounce?.cancel(); + _searchController.clear(); + setState(() => _query = ''); + }, + ), + ) + : null, + prefix: Padding( + padding: const EdgeInsetsDirectional.only( + start: 10, + ), + child: Icon( + windowsBusyMaxGlyph(BusyMaxGlyph.search), + ), ), + onChanged: (value) { + if (!_searchActive) _activateSearch(); + _searchDebounce?.cancel(); + _searchDebounce = Timer( + const Duration(milliseconds: 250), + () { + if (!mounted) return; + _query = value; + _reload(); + }, + ); + }, ), - onChanged: (value) { - _searchDebounce?.cancel(); - _searchDebounce = Timer( - const Duration(milliseconds: 250), - () { - if (!mounted) return; - _query = value; - _reload(); - }, - ); - }, ), - ), - const SizedBox(width: 12), - _ViewModeMenu(mode: _mode, onChanged: _setMode), - ], - ), - ), - Expanded( - child: FutureBuilder>( - future: _itemsFor( - range: range, - accounts: accounts, - visibility: visibility, - ), - builder: (context, snapshot) { - if (snapshot.connectionState != - ConnectionState.done && - !snapshot.hasData) { - return const Center(child: ProgressRing()); - } - if (snapshot.hasError) { - return Center( - child: InfoBar( - title: Text(l10n.scheduleUnavailable), - severity: InfoBarSeverity.error, - action: Button( - onPressed: _reload, - child: Text(l10n.retry), + const SizedBox(width: 12), + if (_searchActive) + Tooltip( + message: l10n.close, + child: IconButton( + icon: const Icon(FluentIcons.cancel), + onPressed: _dismissSearch, ), - ), - ); - } - final items = snapshot.data ?? const []; - if (items.isEmpty && - _query.isNotEmpty && - _mode != ScheduleViewMode.agenda) { - return _WindowsScheduleEmptyState( - searching: _query.isNotEmpty, - noVisibleSources: - visibility - .visibleCalendarSourceIds - .isEmpty && - visibility.visibleTaskListKeys.isEmpty, - ); - } - return _ScheduleModeView( - mode: _mode, - selectedDate: _selectedDate, + ) + else + _ViewModeMenu(mode: _mode, onChanged: _setMode), + ], + ), + ), + Expanded( + child: FutureBuilder>( + future: _itemsFor( range: range, - items: items, - locale: locale, - onOpen: _showItemDetails, - onSelectDate: _openDay, - onLoadMoreAgenda: _loadMoreAgenda, - onVisibleDateChanged: _selectDate, - onEmptySlot: (start) => - unawaited(_createEvent(start: start)), - onRangeCreated: - writableCalendarSources(sources).isEmpty - ? null - : (interval) => unawaited( - _createEvent(interval: interval), + accounts: accounts, + visibility: visibility, + ), + builder: (context, snapshot) { + if (snapshot.connectionState != + ConnectionState.done && + (_searchActive || !snapshot.hasData)) { + return const Center(child: ProgressRing()); + } + if (snapshot.hasError) { + return Center( + child: InfoBar( + title: Text(l10n.scheduleUnavailable), + severity: InfoBarSeverity.error, + action: Button( + onPressed: _reload, + child: Text(l10n.retry), ), - onReschedule: _rescheduleEvent, - onTaskCompletionChanged: _setTaskCompleted, - dayStartMinute: ref - .read(appSettingsControllerProvider) - .scheduleDayStartMinute, - dayEndMinute: ref - .read(appSettingsControllerProvider) - .scheduleDayEndMinute, - ); - }, + ), + ); + } + final items = snapshot.data ?? const []; + if (items.isEmpty && _searchActive) { + return _WindowsScheduleEmptyState( + searching: _searchActive, + noVisibleSources: + _searchCriteria?.hasSources == false, + ); + } + if (_searchActive) { + return _AgendaList( + key: const ValueKey('windows-search-results'), + items: items, + locale: locale, + onOpen: _showItemDetails, + groupByDate: false, + searchCriteria: _searchCriteria, + searchQuery: _query, + ); + } + return _ScheduleModeView( + mode: _mode, + selectedDate: _selectedDate, + range: range, + items: items, + locale: locale, + onOpen: _showItemDetails, + onSelectDate: _openDay, + onLoadMoreAgenda: _loadMoreAgenda, + onVisibleDateChanged: _selectDate, + onEmptySlot: (start) => + unawaited(_createEvent(start: start)), + onRangeCreated: + writableCalendarSources(sources).isEmpty + ? null + : (interval) => unawaited( + _createEvent(interval: interval), + ), + onReschedule: _rescheduleEvent, + onTaskCompletionChanged: _setTaskCompleted, + dayStartMinute: ref + .read(appSettingsControllerProvider) + .scheduleDayStartMinute, + dayEndMinute: ref + .read(appSettingsControllerProvider) + .scheduleDayEndMinute, + ); + }, + ), ), - ), - ], + ], + ), ), - ), - ], + ], + ), ), ), ); @@ -658,10 +713,64 @@ class _WindowsSchedulePageState extends ConsumerState { if (changed && mounted) _reload(); } - void _focusSearch() => _searchFocusNode.requestFocus(); + void _initializeSearch() { + final visibility = _latestVisibility; + if (visibility == null) return; + final now = DateTime.now(); + _initialSearchCriteria = ScheduleSearchCriteria( + referenceDate: DateTime(now.year, now.month, now.day), + firstWeekday: DateTime.monday, + sourceIds: visibility.visibleCalendarSourceIds, + taskListKeys: visibility.visibleTaskListKeys, + ); + _searchCriteria = _initialSearchCriteria; + } + + void _activateSearch() { + if (_searchActive) return; + setState(() { + _sourcePaneBeforeSearch = _sourcePaneCollapsed; + _searchActive = true; + _initializeSearch(); + }); + } + + void _searchFocusChanged() { + if (_searchFocusNode.hasFocus) _activateSearch(); + } + + void _focusSearch() { + _activateSearch(); + _searchFocusNode.requestFocus(); + } + + Widget _searchPane( + List accounts, + List sources, + List taskLists, { + VoidCallback? refresh, + }) => WindowsScheduleSearchPane( + value: _searchCriteria!, + accounts: accounts, + sources: sources, + taskLists: taskLists, + onChanged: (value) { + setState(() => _searchCriteria = value); + refresh?.call(); + }, + onClear: () { + setState(() => _searchCriteria = _initialSearchCriteria); + refresh?.call(); + }, + ); void _dismissSearch() { - if (!_searchFocusNode.hasFocus && _query.isEmpty) return; + if (!_searchActive) return; + _searchActive = false; + _sourcePaneCollapsed = _sourcePaneBeforeSearch ?? _sourcePaneCollapsed; + _sourcePaneBeforeSearch = null; + _searchCriteria = null; + _initialSearchCriteria = null; _searchDebounce?.cancel(); _searchController.clear(); _query = ''; @@ -670,6 +779,7 @@ class _WindowsSchedulePageState extends ConsumerState { } void _invokeUnmodifiedShortcut(VoidCallback callback) { + if (_searchActive) return; final focusContext = FocusManager.instance.primaryFocus?.context; if (focusContext != null && (focusContext.widget is EditableText || @@ -712,7 +822,7 @@ class _WindowsSchedulePageState extends ConsumerState { .toList() ..sort(); final accountIds = [for (final account in accounts) account.id]..sort(); - final key = [ + final normalKey = [ _refreshRevision, _mode.name, range.start.toIso8601String(), @@ -722,6 +832,7 @@ class _WindowsSchedulePageState extends ConsumerState { calendarIds.join(','), taskKeys.join(','), ].join('|'); + final key = (normalKey, _searchActive, _searchCriteria); if (_itemsKey == key && _itemsFuture != null) return _itemsFuture!; _itemsKey = key; final filters = ScheduleFilters( @@ -733,10 +844,17 @@ class _WindowsSchedulePageState extends ConsumerState { taskListFilterActive: true, includeCalendarEvents: true, includeTasks: true, - showCompletedTasks: true, + taskCompletion: ScheduleTaskCompletion.all, showNoDateTasks: _mode == ScheduleViewMode.agenda, ); - _itemsFuture = _loadItems(range, filters); + _itemsFuture = _searchActive && _searchCriteria != null + ? ref + .read(scheduleRepositoryProvider) + .listItems( + range: _searchCriteria!.range ?? range, + filters: _searchCriteria!.filters(_query), + ) + : _loadItems(range, filters); return _itemsFuture!; } @@ -748,7 +866,7 @@ class _WindowsSchedulePageState extends ConsumerState { final items = [ ...await repository.listItems(range: range, filters: filters), ]; - if (_mode == ScheduleViewMode.agenda && _query.trim().isEmpty) { + if (_mode == ScheduleViewMode.agenda) { final overdue = await repository.listOverdueTasks( before: range.start, limit: _agendaTaskLimit, @@ -897,6 +1015,32 @@ class _WindowsSchedulePageState extends ConsumerState { required List taskLists, required ScheduleSourceVisibility visibility, }) { + if (_searchActive && _searchCriteria != null) { + return showDialog( + context: context, + builder: (context) => StatefulBuilder( + builder: (context, update) => ContentDialog( + title: Text(AppLocalizations.of(context).searchFilters), + content: SizedBox( + width: 360, + height: math.min(MediaQuery.sizeOf(context).height - 180, 620), + child: _searchPane( + accounts, + sources, + taskLists, + refresh: () => update(() {}), + ), + ), + actions: [ + Button( + onPressed: () => Navigator.pop(context), + child: Text(AppLocalizations.of(context).close), + ), + ], + ), + ), + ); + } final visibleCalendars = {...visibility.visibleCalendarSourceIds}; final visibleTaskLists = {...visibility.visibleTaskListKeys}; return showDialog( @@ -1519,6 +1663,9 @@ class _ScheduleModeView extends StatelessWidget { class _AgendaList extends StatelessWidget { const _AgendaList({ + super.key, + this.searchCriteria, + this.searchQuery = '', required this.items, required this.locale, required this.onOpen, @@ -1526,6 +1673,8 @@ class _AgendaList extends StatelessWidget { this.onLoadMore, }); + final ScheduleSearchCriteria? searchCriteria; + final String searchQuery; final List items; final String locale; final ValueChanged onOpen; @@ -1578,6 +1727,8 @@ class _AgendaList extends StatelessWidget { rows.add( _ScheduleItemCard( item: item, + searchCriteria: searchCriteria, + searchQuery: searchQuery, locale: locale, onPressed: () => onOpen(item), ), @@ -2044,7 +2195,9 @@ class _WindowsScheduleEmptyState extends StatelessWidget { const SizedBox(height: 12), Text( searching - ? l10n.scheduleNoSearchResults + ? noVisibleSources + ? l10n.searchNoSources + : l10n.scheduleNoSearchResults : noVisibleSources ? l10n.scheduleNoSources : l10n.noEventsOrTasks, @@ -2062,11 +2215,15 @@ class _WindowsScheduleEmptyState extends StatelessWidget { class _ScheduleItemCard extends StatelessWidget { const _ScheduleItemCard({ + this.searchCriteria, + this.searchQuery = '', required this.item, required this.locale, required this.onPressed, }); + final ScheduleSearchCriteria? searchCriteria; + final String searchQuery; final ScheduleItem item; final String locale; final VoidCallback onPressed; @@ -2092,10 +2249,19 @@ class _ScheduleItemCard extends StatelessWidget { : null, ), subtitle: Text( - [ - _itemDateLabel(context, item, locale), - ?item.sourceName, - ].where((value) => value.isNotEmpty).join(' · '), + searchCriteria != null + ? scheduleSearchResultText( + context, + item, + searchCriteria!, + searchQuery, + ) + : [ + _itemDateLabel(context, item, locale), + ?item.sourceName, + ].where((value) => value.isNotEmpty).join(' · '), + maxLines: 2, + overflow: TextOverflow.ellipsis, ), onPressed: onPressed, ), diff --git a/lib/src/ui/windows/windows_schedule_search_pane.dart b/lib/src/ui/windows/windows_schedule_search_pane.dart new file mode 100644 index 00000000..0667d8f4 --- /dev/null +++ b/lib/src/ui/windows/windows_schedule_search_pane.dart @@ -0,0 +1,269 @@ +import 'package:fluent_ui/fluent_ui.dart'; + +import '../../../l10n/generated/app_localizations.dart'; +import '../../features/accounts/data/accounts_repository.dart'; +import '../../features/calendar/data/calendar_repository.dart'; +import '../../features/task_lists/data/task_lists_repository.dart'; +import '../../features/schedule/presentation/schedule_search_labels.dart'; +import '../../schedule/schedule_search_criteria.dart'; +import '../../schedule/schedule_filters.dart'; +import '../common/busymax_glyph.dart'; +import 'windows_busymax_glyphs.dart'; + +class WindowsScheduleSearchPane extends StatelessWidget { + const WindowsScheduleSearchPane({ + super.key, + required this.value, + required this.onChanged, + required this.onClear, + required this.accounts, + required this.sources, + required this.taskLists, + }); + final ScheduleSearchCriteria value; + final ValueChanged onChanged; + final VoidCallback onClear; + final List accounts; + final List sources; + final List taskLists; + + @override + Widget build(BuildContext context) { + final l = AppLocalizations.of(context); + return ListView( + key: const ValueKey('windows-search-filters'), + padding: const EdgeInsetsDirectional.all(16), + children: [ + Text( + l.searchFilters, + style: FluentTheme.of(context).typography.subtitle, + ), + const SizedBox(height: 16), + _select( + l.searchType, + value.type, + ScheduleSearchType.values, + (v) => searchTypeLabel(l, v), + (v) => onChanged(value.copyWith(type: v)), + ), + _select( + l.searchDate, + value.date, + ScheduleSearchDate.values, + (v) => searchDateLabel(l, v), + (v) => onChanged(value.copyWith(date: v)), + ), + if (value.date == ScheduleSearchDate.custom) ...[ + _date(l.startDate, value.customStart ?? value.referenceDate, true), + _date(l.endDate, value.customEnd ?? value.referenceDate, false), + ], + if (value.includesTasks) ...[ + _select( + l.searchTaskStatus, + value.taskCompletion, + ScheduleTaskCompletion.values, + (v) => searchCompletionLabel(l, v), + (v) => onChanged(value.copyWith(taskCompletion: v)), + ), + _select( + l.searchTaskDue, + value.taskDueState, + ScheduleTaskDueState.values, + (v) => searchDueLabel(l, v), + (v) => onChanged(value.copyWith(taskDueState: v)), + ), + ], + if (value.includesEvents) + _text( + l.searchPerson, + value.person, + (v) => onChanged(value.copyWith(person: v)), + ), + _text( + l.location, + value.location, + (v) => onChanged(value.copyWith(location: v)), + ), + Text( + l.searchSources, + style: FluentTheme.of(context).typography.bodyStrong, + ), + for (final account in accounts) ...[ + Padding( + padding: const EdgeInsetsDirectional.only(top: 12, bottom: 8), + child: Text( + account.displayLabel, + style: FluentTheme.of(context).typography.bodyStrong, + ), + ), + if (value.includesEvents) + for (final source in sources.where( + (s) => s.accountId == account.id && !s.isDeleted, + )) + Padding( + padding: const EdgeInsetsDirectional.only(bottom: 8), + child: _sourceToggle( + source.summary, + BusyMaxGlyph.calendar, + checked: value.sourceIds.contains(source.id), + onChanged: (selected) { + final ids = {...value.sourceIds}; + if (selected == true) { + ids.add(source.id); + } else { + ids.remove(source.id); + } + onChanged(value.copyWith(sourceIds: ids)); + }, + ), + ), + if (value.includesTasks) + for (final list in taskLists.where( + (s) => s.accountId == account.id && !s.pendingDelete, + )) + Padding( + padding: const EdgeInsetsDirectional.only(bottom: 8), + child: _sourceToggle( + list.title, + BusyMaxGlyph.task, + checked: value.taskListKeys.contains( + ScheduleTaskListKey( + accountId: list.accountId, + taskListId: list.id, + ), + ), + onChanged: (selected) { + final keys = {...value.taskListKeys}; + final key = ScheduleTaskListKey( + accountId: list.accountId, + taskListId: list.id, + ); + if (selected == true) { + keys.add(key); + } else { + keys.remove(key); + } + onChanged(value.copyWith(taskListKeys: keys)); + }, + ), + ), + ], + if (!value.hasSources) + Padding( + padding: const EdgeInsetsDirectional.symmetric(vertical: 12), + child: Text(l.searchNoSources), + ), + const SizedBox(height: 12), + Button(onPressed: onClear, child: Text(l.searchClearFilters)), + ], + ); + } + + Widget _sourceToggle( + String title, + BusyMaxGlyph glyph, { + required bool checked, + required ValueChanged onChanged, + }) => LayoutBuilder( + builder: (context, constraints) => Checkbox( + checked: checked, + onChanged: onChanged, + content: SizedBox( + width: (constraints.maxWidth - 36).clamp(0, double.infinity), + child: Row( + children: [ + Icon(windowsBusyMaxGlyph(glyph), size: 16), + const SizedBox(width: 8), + Expanded( + child: Text(title, maxLines: 1, overflow: TextOverflow.ellipsis), + ), + ], + ), + ), + ), + ); + + Widget _select( + String label, + T selected, + List values, + String Function(T) name, + ValueChanged changed, + ) => Padding( + padding: const EdgeInsetsDirectional.only(bottom: 12), + child: InfoLabel( + label: label, + child: ComboBox( + isExpanded: true, + value: selected, + items: [ + for (final v in values) ComboBoxItem(value: v, child: Text(name(v))), + ], + onChanged: (v) { + if (v != null) changed(v); + }, + ), + ), + ); + Widget _text(String label, String text, ValueChanged changed) => + Padding( + padding: const EdgeInsetsDirectional.only(bottom: 16), + child: _FilterText(label: label, value: text, onChanged: changed), + ); + Widget _date(String label, DateTime date, bool start) => Padding( + padding: const EdgeInsetsDirectional.only(bottom: 12), + child: InfoLabel( + label: label, + child: DatePicker( + startDate: DateTime(1900), + endDate: DateTime(2200), + selected: date, + onChanged: (picked) { + final day = DateTime(picked.year, picked.month, picked.day); + final from = start ? day : value.customStart ?? value.referenceDate; + final to = start ? value.customEnd ?? value.referenceDate : day; + onChanged( + value.copyWith( + customStart: from.isAfter(to) ? day : from, + customEnd: to.isBefore(from) ? day : to, + ), + ); + }, + ), + ), + ); +} + +class _FilterText extends StatefulWidget { + const _FilterText({ + required this.label, + required this.value, + required this.onChanged, + }); + final String label; + final String value; + final ValueChanged onChanged; + @override + State<_FilterText> createState() => _FilterTextState(); +} + +class _FilterTextState extends State<_FilterText> { + late final _controller = TextEditingController(text: widget.value); + @override + void didUpdateWidget(covariant _FilterText oldWidget) { + super.didUpdateWidget(oldWidget); + if (_controller.text != widget.value) _controller.text = widget.value; + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) => InfoLabel( + label: widget.label, + child: TextBox(controller: _controller, onChanged: widget.onChanged), + ); +} diff --git a/lib/src/ui/windows/windows_tasks_page.dart b/lib/src/ui/windows/windows_tasks_page.dart index ed3087c3..681eda72 100644 --- a/lib/src/ui/windows/windows_tasks_page.dart +++ b/lib/src/ui/windows/windows_tasks_page.dart @@ -61,7 +61,9 @@ class _WindowsTasksPageState extends ConsumerState { taskListFilterActive: _listKey != null, includeCalendarEvents: false, includeTasks: true, - showCompletedTasks: _showCompleted, + taskCompletion: _showCompleted + ? ScheduleTaskCompletion.all + : ScheduleTaskCompletion.open, showNoDateTasks: true, ); return repository.listAllTasks(filters: filters); diff --git a/linux/runner/my_application.cc b/linux/runner/my_application.cc index 5ba952ec..a226ea8a 100644 --- a/linux/runner/my_application.cc +++ b/linux/runner/my_application.cc @@ -4285,6 +4285,9 @@ static void header_bar_method_call_cb(FlMethodChannel* channel, set_header_search_state(self, fl_method_bool_arg(args), self->header_search_query); respond_success(method_call); + } else if (strcmp(method, "focusContent") == 0) { + focus_flutter_view(self); + respond_bool(method_call, TRUE); } else if (strcmp(method, "focusSearch") == 0) { respond_bool(method_call, focus_header_search_entry(self)); } else if (strcmp(method, "setCanShowSidebar") == 0) { diff --git a/test/features/schedule/presentation/schedule_search_platform_test.dart b/test/features/schedule/presentation/schedule_search_platform_test.dart new file mode 100644 index 00000000..6161d883 --- /dev/null +++ b/test/features/schedule/presentation/schedule_search_platform_test.dart @@ -0,0 +1,504 @@ +import 'package:busymax/l10n/generated/app_localizations.dart'; +import 'package:busymax/src/android/presentation/android_schedule_screen.dart'; +import 'package:busymax/src/app/app_bootstrap.dart'; +import 'package:busymax/src/app/busymax_design.dart'; +import 'package:busymax/src/calendar_providers/calendar_sync_dto.dart'; +import 'package:busymax/src/db/app_database.dart'; +import 'package:busymax/src/features/accounts/data/accounts_repository.dart'; +import 'package:busymax/src/features/calendar/data/calendar_repository.dart'; +import 'package:busymax/src/features/schedule/presentation/schedule_agenda_view.dart'; +import 'package:busymax/src/features/schedule/presentation/schedule_month_view.dart'; +import 'package:busymax/src/features/schedule/presentation/schedule_search_filters.dart'; +import 'package:busymax/src/features/schedule/presentation/schedule_sidebar.dart'; +import 'package:busymax/src/features/schedule/presentation/schedule_workspace.dart'; +import 'package:busymax/src/features/task_lists/data/task_lists_repository.dart'; +import 'package:busymax/src/features/tasks/data/tasks_repository.dart'; +import 'package:busymax/src/platform/linux_header_bar_provider.dart'; +import 'package:busymax/src/platform/linux_header_bar_service.dart'; +import 'package:busymax/src/providers/busy_provider.dart'; +import 'package:busymax/src/schedule/schedule_filters.dart'; +import 'package:busymax/src/schedule/schedule_repository.dart'; +import 'package:busymax/src/schedule/schedule_search_criteria.dart'; +import 'package:busymax/src/schedule/schedule_view_mode.dart'; +import 'package:busymax/src/ui/windows/windows_schedule_page.dart'; +import 'package:busymax/src/ui/windows/windows_schedule_search_pane.dart'; +import 'package:busymax/src/ui/windows/windows_schedule_source_pane.dart'; +import 'package:drift/drift.dart' hide Column, isNull, isNotNull; +import 'package:fluent_ui/fluent_ui.dart' as fluent; +import 'package:flutter/material.dart'; +import 'package:yaru/yaru.dart'; +import 'package:busymax/src/android/presentation/android_schedule_search_filters.dart'; +import 'package:busymax/src/features/connectivity/network_connectivity_service.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../../support/memory_settings_store.dart'; +import '../../../test_localized_app.dart'; + +void main() { + for (final platform in ['linux', 'windows']) { + testWidgets( + '$platform search swaps sidebar, lists all local matches, keeps temporary sources and restores Month', + (tester) async { + final fixture = await _mount(tester, platform); + await tester.pumpAndSettle(); + final normal = platform == 'linux' + ? find.byType(ScheduleSidebar) + : find.byType(WindowsScheduleSourcePane); + expect(normal, findsOneWidget); + await _searchShortcut(tester); + expect(normal, findsNothing); + final panelFinder = platform == 'linux' + ? find.byType(ScheduleSearchFilters) + : find.byType(WindowsScheduleSearchPane); + expect(panelFinder, findsOneWidget); + final list = find.byKey( + ValueKey( + platform == 'linux' + ? 'schedule-search-results' + : 'windows-search-results', + ), + ); + expect(list, findsOneWidget); + expect(find.text('Far future review'), findsOneWidget); + expect(find.text('No due task'), findsOneWidget); + if (platform == 'linux') { + expect( + tester + .widget(find.byType(ScheduleAgendaView)) + .searchCriteria, + isNotNull, + ); + } + ScheduleSearchCriteria criteria() => platform == 'linux' + ? tester.widget(panelFinder).value + : tester.widget(panelFinder).value; + void change(ScheduleSearchCriteria value) { + if (platform == 'linux') { + tester.widget(panelFinder).onChanged(value); + } else { + tester + .widget(panelFinder) + .onChanged(value); + } + } + + final initial = criteria(); + change( + criteria().copyWith( + type: ScheduleSearchType.tasks, + taskCompletion: ScheduleTaskCompletion.open, + taskDueState: ScheduleTaskDueState.overdue, + ), + ); + await tester.pumpAndSettle(); + expect(find.text('Overdue task'), findsOneWidget); + expect(find.text('Completed task'), findsNothing); + expect(find.text('No due task'), findsNothing); + expect(find.text('Far future review'), findsNothing); + // Exercise the actual source checkbox, then verify repository/settings stayed intact. + final checkbox = platform == 'linux' + ? find + .descendant( + of: panelFinder, + matching: find.byType(YaruCheckboxListTile), + ) + .first + : find + .descendant( + of: panelFinder, + matching: find.byType(fluent.Checkbox), + ) + .first; + await tester.ensureVisible(checkbox); + await tester.tap(checkbox); + await tester.pumpAndSettle(); + expect(criteria().taskListKeys, isEmpty); + expect(find.text('Overdue task'), findsNothing); + expect( + fixture.container + .read(appSettingsControllerProvider) + .isTaskListVisibleInSchedule('account', 'list'), + isTrue, + ); + expect( + (await fixture.db.select(fixture.db.calendarSources).getSingle()) + .selected, + isTrue, + ); + if (platform == 'linux') { + tester.widget(panelFinder).onClear(); + } else { + tester.widget(panelFinder).onClear(); + } + await tester.pumpAndSettle(); + expect(criteria(), initial); + final calendarToggle = platform == 'linux' + ? find + .descendant( + of: panelFinder, + matching: find.byType(YaruCheckboxListTile), + ) + .first + : find + .descendant( + of: panelFinder, + matching: find.byType(fluent.Checkbox), + ) + .first; + await tester.ensureVisible(calendarToggle); + await tester.tap(calendarToggle); + await tester.pumpAndSettle(); + expect(criteria().sourceIds, isEmpty); + expect( + (await fixture.db.select(fixture.db.calendarSources).getSingle()) + .selected, + isTrue, + ); + expect(find.text('Far future review'), findsNothing); + await tester.tap(calendarToggle); + await tester.pumpAndSettle(); + + change( + criteria().copyWith( + type: ScheduleSearchType.events, + person: 'alex@example.com', + ), + ); + await tester.pumpAndSettle(); + expect(find.text('Far future review'), findsOneWidget); + change( + criteria().copyWith( + date: ScheduleSearchDate.custom, + customStart: DateTime(2040, 2, 15), + customEnd: DateTime(2040, 2, 15), + location: 'office', + ), + ); + await tester.pumpAndSettle(); + expect(find.text('Far future review'), findsOneWidget); + change( + criteria().copyWith( + customStart: DateTime(2040, 2, 16), + customEnd: DateTime(2040, 2, 16), + ), + ); + await tester.pumpAndSettle(); + expect(find.text('Far future review'), findsNothing); + change(criteria().copyWith(date: ScheduleSearchDate.any)); + await tester.pumpAndSettle(); + // Clearing only the entry retains Person and the temporary scope. + final entry = platform == 'linux' + ? find.descendant( + of: find.byType(BusyMaxSearchField), + matching: find.byType(TextField), + ) + : find + .descendant( + of: find.byType(fluent.TextBox), + matching: find.byType(EditableText), + ) + .last; + await tester.enterText(entry, 'unmatched'); + await tester.pump(const Duration(milliseconds: 300)); + await tester.pumpAndSettle(); + expect(find.text('Far future review'), findsNothing); + await tester.enterText(entry, ''); + await tester.pump(const Duration(milliseconds: 300)); + await tester.pumpAndSettle(); + expect(criteria().person, 'alex@example.com'); + expect(find.text('Far future review'), findsOneWidget); + await tester.tap(find.text('Far future review')); + await tester.pumpAndSettle(); + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pumpAndSettle(); + expect(panelFinder, findsOneWidget); + expect(criteria().person, 'alex@example.com'); + + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pumpAndSettle(); + expect(normal, findsOneWidget); + expect(panelFinder, findsNothing); + expect( + platform == 'linux' + ? find.byType(ScheduleMonthView) + : find.byType(WindowsScheduleMonthView), + findsOneWidget, + ); + expect( + fixture.container + .read(appSettingsControllerProvider) + .scheduleViewMode, + ScheduleViewMode.month, + ); + expect(tester.takeException(), isNull); + }, + ); + testWidgets( + '$platform narrow search opens full filters through sidebar action', + (tester) async { + await _mount(tester, platform, width: 650); + await tester.pumpAndSettle(); + await _searchShortcut(tester); + expect( + platform == 'linux' + ? find.byType(ScheduleSearchFilters) + : find.byType(WindowsScheduleSearchPane), + findsNothing, + ); + await tester.sendKeyEvent(LogicalKeyboardKey.f9); + await tester.pumpAndSettle(); + expect( + platform == 'linux' + ? find.byType(ScheduleSearchFilters) + : find.byType(WindowsScheduleSearchPane), + findsOneWidget, + ); + expect( + find.text('Custom range'), + findsNothing, + ); // available through Date selection, no second semantics + expect(tester.takeException(), isNull); + }, + ); + } + testWidgets( + 'Android tune switches from ordinary sources to temporary search filters and empty query lists results', + (tester) async { + final fixture = await _mount(tester, 'android', width: 480); + await tester.pumpAndSettle(); + await tester.tap(find.byIcon(Icons.tune)); + await tester.pumpAndSettle(); + expect(find.byType(AndroidScheduleSearchFilters), findsNothing); + expect(find.byType(CheckboxListTile), findsWidgets); + Navigator.of(tester.element(find.byType(CheckboxListTile).first)).pop(); + await tester.pumpAndSettle(); + await tester.tap(find.byIcon(Icons.search)); + await tester.pumpAndSettle(); + expect( + find.byKey(const ValueKey('android-search-results')), + findsOneWidget, + ); + expect(find.text('Far future review'), findsOneWidget); + await tester.tap(find.byIcon(Icons.tune)); + await tester.pumpAndSettle(); + final panel = find.byType(AndroidScheduleSearchFilters); + expect(panel, findsOneWidget); + var widget = tester.widget(panel); + final calendarToggle = find + .descendant(of: panel, matching: find.byType(CheckboxListTile)) + .first; + await tester.ensureVisible(calendarToggle); + await tester.tap(calendarToggle); + await tester.pumpAndSettle(); + expect( + tester.widget(panel).value.sourceIds, + isEmpty, + ); + expect( + (await fixture.db.select(fixture.db.calendarSources).getSingle()) + .selected, + isTrue, + ); + await tester.tap(calendarToggle); + await tester.pumpAndSettle(); + widget = tester.widget(panel); + + widget.onChanged( + widget.value.copyWith( + type: ScheduleSearchType.tasks, + taskDueState: ScheduleTaskDueState.noDueDate, + ), + ); + await tester.pumpAndSettle(); + widget = tester.widget(panel); + final checkbox = find + .descendant(of: panel, matching: find.byType(CheckboxListTile)) + .first; + await tester.ensureVisible(checkbox); + await tester.tap(checkbox); + await tester.pumpAndSettle(); + expect( + tester.widget(panel).value.taskListKeys, + isEmpty, + ); + expect( + fixture.container + .read(appSettingsControllerProvider) + .isTaskListVisibleInSchedule('account', 'list'), + isTrue, + ); + await tester.tap(checkbox); + await tester.pumpAndSettle(); + Navigator.of(tester.element(panel)).pop(); + await tester.pumpAndSettle(); + expect(find.text('No due task'), findsOneWidget); + expect(find.text('Overdue task'), findsNothing); + expect( + find.byKey(const ValueKey('android-search-results')), + findsOneWidget, + ); + await tester.tap(find.byIcon(Icons.close)); + await tester.pumpAndSettle(); + expect( + find.byKey(const ValueKey('android-search-results')), + findsNothing, + ); + expect( + fixture.container + .read(appSettingsControllerProvider) + .androidScheduleViewMode, + ScheduleViewMode.month, + ); + expect( + (await fixture.db.select(fixture.db.calendarSources).getSingle()) + .selected, + isTrue, + ); + expect(tester.takeException(), isNull); + }, + ); +} + +Future _searchShortcut(WidgetTester tester) async { + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.keyF); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + await tester.pumpAndSettle(); +} + +Future<({AppDatabase db, ProviderContainer container})> _mount( + WidgetTester tester, + String platform, { + double width = 1280, +}) async { + tester.view + ..physicalSize = Size(width, 900) + ..devicePixelRatio = 1; + addTearDown(tester.view.reset); + final db = AppDatabase.memoryForTests(); + final calendar = CalendarRepository(database: db); + const now = '2026-01-01T00:00:00.000Z'; + await db + .into(db.accounts) + .insert( + AccountsCompanion.insert( + id: 'account', + provider: 'google', + authority: 'https://accounts.google.com', + providerAccountId: 'alex', + credentialKind: 'oauth', + authState: const Value('signed_in'), + email: const Value('alex@example.com'), + createdAtUtc: now, + updatedAtUtc: now, + ), + ); + await db.taskListsDao.upsertTaskList( + TaskListsCompanion.insert( + accountId: 'account', + id: 'list', + title: 'Work tasks', + rawJson: '{}', + createdLocalAtUtc: now, + updatedLocalAtUtc: now, + ), + ); + await calendar.upsertSource( + accountId: 'account', + source: const CalendarSourceDto( + provider: BusyProvider.google, + providerCalendarId: 'calendar', + summary: 'Work calendar', + ), + ); + await calendar.upsertEvent( + accountId: 'account', + event: const CalendarEventDto( + provider: BusyProvider.google, + providerCalendarId: 'calendar', + providerEventId: 'far', + title: 'Far future review', + allDay: true, + startDate: '2040-02-15', + endDate: '2040-02-16', + location: 'Office', + attendeesJson: [ + {'email': 'alex@example.com'}, + ], + ), + ); + for (final entry in [ + ('overdue', 'Overdue task', '2020-01-01', 'needsAction'), + ('done', 'Completed task', '2020-01-01', 'completed'), + ('undated', 'No due task', null, 'needsAction'), + ]) { + await db.tasksDao.upsertTask( + TasksCompanion.insert( + accountId: 'account', + taskListId: 'list', + id: entry.$1, + title: entry.$2, + dueUtc: Value(entry.$3), + status: Value(entry.$4), + taskLocation: const Value('Office'), + rawJson: '{}', + createdLocalAtUtc: now, + updatedLocalAtUtc: now, + ), + ); + } + final header = LinuxHeaderBarService(isLinux: false); + final container = ProviderContainer( + overrides: [ + networkAvailabilityProvider.overrideWith( + (ref) => Stream.value(NetworkAvailability.online), + ), + databaseProvider.overrideWithValue(db), + localTimeZoneProvider.overrideWithValue('UTC'), + linuxHeaderBarServiceProvider.overrideWithValue(header), + localSettingsStoreProvider.overrideWithValue(MemorySettingsStore()), + initialAppSettingsProvider.overrideWithValue( + AppSettings.defaults().copyWith( + scheduleViewMode: ScheduleViewMode.month, + androidScheduleViewMode: ScheduleViewMode.month, + ), + ), + accountsRepositoryProvider.overrideWithValue( + AccountsRepository(database: db), + ), + calendarRepositoryProvider.overrideWithValue(calendar), + scheduleRepositoryProvider.overrideWithValue(ScheduleRepository(db)), + taskListsRepositoryForAccountProvider.overrideWith( + (ref, id) => TaskListsRepository(database: db, accountId: id), + ), + tasksRepositoryForAccountProvider.overrideWith( + (ref, id) => TasksRepository(database: db, accountId: id), + ), + ], + ); + addTearDown(() async { + await tester.pumpWidget(const SizedBox.shrink()); + container.dispose(); + header.dispose(); + await db.close(); + }); + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: platform == 'windows' + ? fluent.FluentApp( + localizationsDelegates: const [AppLocalizations.delegate], + supportedLocales: AppLocalizations.supportedLocales, + home: const WindowsSchedulePage(), + ) + : localizedTestApp( + child: platform == 'linux' + ? const ScheduleWorkspace() + : const AndroidScheduleScreen(), + ), + ), + ); + return (db: db, container: container); +} diff --git a/test/features/schedule/presentation/schedule_views_test.dart b/test/features/schedule/presentation/schedule_views_test.dart index e8593304..193fcc03 100644 --- a/test/features/schedule/presentation/schedule_views_test.dart +++ b/test/features/schedule/presentation/schedule_views_test.dart @@ -3472,40 +3472,6 @@ void main() { expect(popover, isNot(contains('BoxShadow('))); }); - test( - 'schedule search renders query results instead of current range only', - () { - final workspace = File( - 'lib/src/features/schedule/presentation/schedule_workspace.dart', - ).readAsStringSync(); - final agenda = File( - 'lib/src/features/schedule/presentation/schedule_agenda_view.dart', - ).readAsStringSync(); - final repository = File( - 'lib/src/schedule/schedule_repository.dart', - ).readAsStringSync(); - - expect( - workspace, - contains('final searchHasQuery = _searchQuery.trim().isNotEmpty'), - ); - expect(workspace, contains('_rangeForSearchResults(items, range)')); - expect(workspace, contains('? ScheduleViewMode.agenda')); - expect( - repository, - contains('final searching = filters.query.trim().isNotEmpty'), - ); - expect(repository, contains('!searching && !_intersects')); - expect(agenda, contains('groups.keys')); - expect(agenda, contains('ColoredBox(')); - expect( - agenda, - contains('color: BusyMaxSurfaceColors.of(context).window'), - ); - expect(agenda, isNot(contains('_daysInRange'))); - }, - ); - test('agenda list groups use the shared grouped row surface', () { final agenda = File( 'lib/src/features/schedule/presentation/schedule_agenda_view.dart', @@ -4217,7 +4183,7 @@ void main() { expect(sidebar, contains('required this.firstWeekday')); expect(sidebar, contains('firstWeekday: firstWeekday')); expect(workspace, contains('firstWeekday: _firstWeekday(context)')); - expect(workspace, contains('final miniCalendarItemsFuture = ref')); + expect(workspace, contains('final miniCalendarItemsFuture =')); expect(workspace, contains('ScheduleRange.month(')); expect(workspace, contains('showNoDateTasks: false')); expect(workspace, contains('items: miniCalendarItems')); @@ -4576,7 +4542,9 @@ void main() { expect(toolbar, contains('agendaLabel: context.l10n.viewAgenda')); expect( workspace, - contains('navigationVisible: _mode != ScheduleViewMode.agenda'), + contains( + 'navigationVisible: !_searchActive && _mode != ScheduleViewMode.agenda', + ), ); expect( workspace, @@ -4600,7 +4568,7 @@ void main() { expect( source, contains( - 'showNoDateTasks: searchHasQuery || ' + 'showNoDateTasks: searchActive || ' '_mode != ScheduleViewMode.agenda', ), ); @@ -4612,7 +4580,7 @@ void main() { expect(source, contains('limit: _agendaOverdueTaskLimit')); expect(source, contains('final noDateTasks = repository.listNoDateTasks')); expect(source, contains('limit: _agendaNoDateTaskLimit')); - expect(source, contains('showCompletedTasks: false')); + expect(source, contains('taskCompletion: ScheduleTaskCompletion.open')); expect(source, contains('repository.includeTaskAncestors')); expect(source, contains('hasMoreOverdueTasks: overduePage.hasMore')); expect(source, contains('hasMoreNoDateTasks: noDatePage.hasMore')); diff --git a/test/features/schedule/presentation/schedule_workspace_states_test.dart b/test/features/schedule/presentation/schedule_workspace_states_test.dart index a98ea017..e45da6b2 100644 --- a/test/features/schedule/presentation/schedule_workspace_states_test.dart +++ b/test/features/schedule/presentation/schedule_workspace_states_test.dart @@ -8,6 +8,7 @@ import 'package:busymax/src/db/app_database.dart'; import 'package:busymax/src/features/accounts/data/accounts_repository.dart'; import 'package:busymax/src/features/schedule/presentation/schedule_empty_states.dart'; import 'package:busymax/src/features/schedule/presentation/schedule_sidebar.dart'; +import 'package:busymax/src/features/schedule/presentation/schedule_search_filters.dart'; import 'package:busymax/src/features/schedule/presentation/schedule_workspace.dart'; import 'package:busymax/src/platform/linux_header_bar_service.dart'; import 'package:busymax/src/platform/linux_header_bar_provider.dart'; @@ -187,14 +188,28 @@ void main() { await tester.pumpAndSettle(); expect(_searchFieldHasPrimaryFocus(tester), isTrue); - await tester.enterText(find.byType(TextField), 'planning'); + await tester.enterText( + find.descendant( + of: find.byType(BusyMaxSearchField), + matching: find.byType(TextField), + ), + 'planning', + ); await tester.pump(); await tester.tap(find.byIcon(YaruIcons.edit_clear)); await tester.pump(); expect(find.byType(BusyMaxSearchField), findsOneWidget); expect( - tester.widget(find.byType(TextField)).controller!.text, + tester + .widget( + find.descendant( + of: find.byType(BusyMaxSearchField), + matching: find.byType(TextField), + ), + ) + .controller! + .text, '', ); @@ -266,6 +281,32 @@ void main() { containsPair('searchQuery', 'planning'), ); + await tester.tap(find.byType(TextField).first); + await tester.pumpAndSettle(); + expect(calls.where((call) => call.method == 'focusContent'), isNotEmpty); + await tester.enterText(find.byType(TextField).first, 'Taylor'); + await tester.pumpAndSettle(); + expect( + tester + .widget(find.byType(ScheduleSearchFilters)) + .value + .person, + 'Taylor', + ); + expect( + calls.where((call) => call.method == 'setState').last.arguments, + containsPair('searchQuery', 'planning'), + ); + + await headerBarService.handleNativeMethodCall( + const MethodCall('searchFocusChanged', true), + ); + await tester.pumpAndSettle(); + expect( + FocusManager.instance.primaryFocus?.context?.widget, + isNot(isA()), + ); + await headerBarService.handleNativeMethodCall( const MethodCall('searchCleared'), ); @@ -285,6 +326,17 @@ void main() { calls.where((call) => call.method == 'setState').last.arguments, containsPair('searchActive', false), ); + + // Native entry focus must not strand Flutter focus outside the + // workspace's shortcuts after the search session closes. + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.keyF); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + await tester.pumpAndSettle(); + expect(find.byType(ScheduleSearchFilters), findsOneWidget); + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pumpAndSettle(); + expect(find.byType(ScheduleSidebar), findsOneWidget); }, skip: !Platform.isLinux, ); diff --git a/test/features/schedule/schedule_search_test.dart b/test/features/schedule/schedule_search_test.dart index 1d0eda40..a90a094d 100644 --- a/test/features/schedule/schedule_search_test.dart +++ b/test/features/schedule/schedule_search_test.dart @@ -1,4 +1,6 @@ import 'dart:convert'; +import 'package:busymax/src/schedule/schedule_search_match.dart'; +import 'package:busymax/src/schedule/schedule_search_criteria.dart'; import 'package:busymax/src/db/app_database.dart'; import 'package:busymax/src/calendar_providers/calendar_sync_dto.dart'; @@ -8,11 +10,438 @@ import 'package:busymax/src/schedule/schedule_filters.dart'; import 'package:busymax/src/schedule/schedule_range.dart'; import 'package:busymax/src/schedule/schedule_repository.dart'; import 'package:busymax/src/providers/busy_provider.dart'; -import 'package:drift/drift.dart'; +import 'package:drift/drift.dart' hide isNull, isNotNull; import 'package:drift/native.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { + group('shared search criteria', () { + final today = DateTime(2026, 6, 12); + final range = ScheduleRange.day(today); + final event = CalendarScheduleItem( + id: 'meeting', + accountId: 'account', + provider: BusyProvider.microsoft, + sourceId: 'calendar', + providerCalendarId: 'calendar', + title: 'Review', + allDay: false, + start: DateTime(2026, 6, 11, 12), + end: DateTime(2026, 6, 12, 14), + location: 'Office north', + description: '

Launch brief

', + categories: const ['Planning'], + attendees: const [ + { + 'displayName': 'Alex Smith', + 'email': 'alex@example.com', + 'responseStatus': 'accepted', + }, + { + 'emailAddress': { + 'name': 'Robin Jones', + 'address': 'robin@example.com', + }, + 'status': {'response': 'tentative'}, + }, + ], + organizer: const {'name': 'Morgan Lee', 'email': 'morgan@example.com'}, + ); + TaskScheduleItem task({ + bool completed = false, + DateTime? due, + DateTime? start, + }) => TaskScheduleItem( + id: 'task', + accountId: 'account', + provider: BusyProvider.microsoft, + sourceId: 'list', + title: 'Report', + completed: completed, + allDay: true, + start: start, + due: due, + location: 'Office south', + notes: 'Budget appendix', + ); + bool matches(ScheduleItem item, ScheduleFilters filters) => + matchesScheduleFilters(item, filters, range: range); + + for (final term in [ + 'Alex Smith', + 'alex@example.com', + 'Robin Jones', + 'robin@example.com', + 'Morgan Lee', + 'morgan@example.com', + ]) { + test('identity text and Person match $term', () { + expect(matchesScheduleQuery(event, term), isTrue); + expect(matches(event, ScheduleFilters(person: term)), isTrue); + expect(matches(task(), ScheduleFilters(person: term)), isFalse); + }); + } + test( + 'identity extraction ignores response metadata and handles organizer emailAddress', + () { + expect(matchesScheduleQuery(event, 'accepted'), isFalse); + expect(matchesScheduleQuery(event, 'tentative'), isFalse); + const nested = CalendarScheduleItem( + id: 'nested', + accountId: 'a', + provider: BusyProvider.microsoft, + sourceId: 's', + providerCalendarId: 's', + title: 'Meeting', + allDay: true, + organizer: { + 'emailAddress': { + 'name': 'Chris Green', + 'address': 'chris@example.com', + }, + }, + ); + expect( + matchesScheduleQuery(nested, 'Chris Green chris@example.com'), + isTrue, + ); + expect( + matches(event, const ScheduleFilters(person: 'unknown')), + isFalse, + ); + expect( + matches( + task(), + const ScheduleFilters( + includeCalendarEvents: false, + person: 'ignored', + ), + ), + isTrue, + ); + }, + ); + test('task location and cross-field words remain searchable', () { + expect(matchesScheduleQuery(task(), 'REPORT office appendix'), isTrue); + expect(matchesScheduleQuery(event, 'review north alex planning'), isTrue); + }); + test('dedicated Location only constrains location fields', () { + expect(matches(event, const ScheduleFilters(location: 'OFFICE')), isTrue); + expect( + matches(task(), const ScheduleFilters(location: 'office')), + isTrue, + ); + expect( + matches(event, const ScheduleFilters(location: 'Review')), + isFalse, + ); + expect( + matches(task(), const ScheduleFilters(location: 'Budget')), + isFalse, + ); + }); + test('type includes All, Events only and Tasks only', () { + for (final type in ScheduleSearchType.values) { + final criteria = ScheduleSearchCriteria( + type: type, + referenceDate: today, + firstWeekday: DateTime.sunday, + sourceIds: {'calendar'}, + taskListKeys: { + const ScheduleTaskListKey(accountId: 'account', taskListId: 'list'), + }, + ); + expect( + matches(event, criteria.filters('')), + type != ScheduleSearchType.tasks, + ); + expect( + matches(task(), criteria.filters('')), + type != ScheduleSearchType.events, + ); + } + }); + test('bounded date uses overlap and actual task due', () { + const filters = ScheduleFilters(useTaskDueDate: true); + expect(matches(event, filters), isTrue); + expect( + matches(task(due: today, start: DateTime(2026, 6, 1)), filters), + isTrue, + ); + expect( + matches(task(due: DateTime(2026, 6, 13), start: today), filters), + isFalse, + ); + expect(matches(task(start: today), filters), isFalse); + }); + test( + 'Overdue excludes completed, future and undated tasks, preserves events', + () { + final filters = ScheduleFilters( + ignoreDateRange: true, + taskCompletion: ScheduleTaskCompletion.all, + taskDueState: ScheduleTaskDueState.overdue, + referenceDate: today, + ); + expect(matches(task(due: DateTime(2026, 6, 11)), filters), isTrue); + expect( + matches(task(due: DateTime(2026, 6, 11), completed: true), filters), + isFalse, + ); + expect(matches(task(due: today), filters), isFalse); + expect(matches(task(due: DateTime(2026, 6, 13)), filters), isFalse); + expect(matches(task(start: DateTime(2026, 6, 1)), filters), isFalse); + expect(matches(event, filters), isTrue); + }, + ); + test('No due date is independent of start metadata and events', () { + const filters = ScheduleFilters( + ignoreDateRange: true, + taskDueState: ScheduleTaskDueState.noDueDate, + ); + expect(matches(task(start: today), filters), isTrue); + expect(matches(task(due: today), filters), isFalse); + expect(matches(event, filters), isTrue); + }); + test('snippet prefers location and person and never exposes HTML', () { + expect(scheduleMatchContext(event, 'north launch'), 'Office north'); + expect(scheduleMatchContext(event, 'alex'), contains('Alex')); + expect(scheduleMatchContext(event, 'launch'), 'Launch brief'); + expect(scheduleMatchContext(event, 'review'), isNull); + expect( + scheduleMatchContext(event, '', location: 'office'), + 'Office north', + ); + expect( + scheduleMatchContext(event, '', person: 'morgan'), + contains('Morgan'), + ); + }); + test( + 'criteria copy sets, have value equality and respect week start and inclusive custom end', + () { + final ids = {'a', 'b'}; + final first = ScheduleSearchCriteria( + referenceDate: today, + firstWeekday: DateTime.sunday, + sourceIds: ids, + taskListKeys: {}, + ); + ids.clear(); + expect(first.sourceIds, {'a', 'b'}); + final equal = first.copyWith(sourceIds: {'b', 'a'}); + expect(first, equal); + expect(first.hashCode, equal.hashCode); + expect(first.copyWith(person: 'Alex'), isNot(first)); + expect( + first + .copyWith(person: 'Alex') + .copyWith(type: ScheduleSearchType.tasks) + .person, + isEmpty, + ); + expect( + first.copyWith(date: ScheduleSearchDate.thisWeek).range!.start, + DateTime(2026, 6, 7), + ); + final custom = first + .copyWith( + date: ScheduleSearchDate.custom, + customStart: today, + customEnd: DateTime(2026, 6, 14), + ) + .range!; + expect(custom.start, today); + expect(custom.end, DateTime(2026, 6, 15)); + }, + ); + }); + + for (final completion in ScheduleTaskCompletion.values) { + test('repository task completion ${completion.name}', () async { + final db = AppDatabase.memoryForTests(); + addTearDown(db.close); + await _insertScheduleAccount(db, provider: BusyProvider.google); + await _insertTaskList(db); + await _insertTask(db, id: 'open', title: 'Open'); + await _insertTask(db, id: 'done', title: 'Done'); + await (db.update(db.tasks)..where((t) => t.id.equals('done'))).write( + const TasksCompanion(status: Value('completed')), + ); + final items = await ScheduleRepository(db).listItems( + range: ScheduleRange.day(DateTime(2026)), + filters: ScheduleFilters( + ignoreDateRange: true, + taskCompletion: completion, + ), + ); + expect(items.map((i) => i.id).toSet(), switch (completion) { + ScheduleTaskCompletion.open => {'open'}, + ScheduleTaskCompletion.completed => {'done'}, + ScheduleTaskCompletion.all => {'open', 'done'}, + }); + }); + } + test( + 'repository Microsoft due uses later due day and due timezone rather than start', + () async { + final db = AppDatabase.memoryForTests(); + addTearDown(db.close); + await _insertScheduleAccount(db, provider: BusyProvider.microsoft); + await _insertTaskList(db); + await _insertTask(db, id: 'ms', title: 'Report'); + await db + .update(db.tasks) + .write( + const TasksCompanion( + microsoftStartDateTime: Value('2026-06-10T09:00:00'), + microsoftDueDateTime: Value('2026-06-12T12:00:00'), + microsoftDueTimeZone: Value('Pacific Standard Time'), + ), + ); + final repo = ScheduleRepository(db); + const filters = ScheduleFilters(useTaskDueDate: true, query: 'report'); + expect( + await repo.listItems( + range: ScheduleRange.day(DateTime(2026, 6, 10)), + filters: filters, + ), + isEmpty, + ); + final items = await repo.listItems( + range: ScheduleRange.day(DateTime(2026, 6, 12)), + filters: filters, + ); + expect(items, hasLength(1)); + expect( + (items.single as TaskScheduleItem).due, + DateTime.utc(2026, 6, 12, 19).toLocal(), + ); + }, + ); + test( + 'repository DAV DTSTART-only legacy dueUtc does not become an actual due', + () async { + final db = AppDatabase.memoryForTests(); + addTearDown(db.close); + await _insertDavScheduleFixture(db, authState: 'signed_in'); + await _insertDavTask( + db, + id: 'start-only', + title: 'Start only', + dueUtc: '2026-06-10', + providerMetadata: { + 'nativeStart': {'raw': '20260610', 'kind': 'date'}, + }, + ); + final repo = ScheduleRepository(db); + final items = await repo.listItems( + range: ScheduleRange.day(DateTime(2026, 6, 10)), + filters: const ScheduleFilters( + ignoreDateRange: true, + useTaskDueDate: true, + taskDueState: ScheduleTaskDueState.noDueDate, + ), + ); + expect(items, hasLength(1)); + expect((items.single as TaskScheduleItem).due, isNull); + expect(items.single.start, DateTime(2026, 6, 10)); + expect( + await repo.listItems( + range: ScheduleRange.day(DateTime(2026, 6, 10)), + filters: const ScheduleFilters(useTaskDueDate: true), + ), + isEmpty, + ); + }, + ); + test( + 'repository bounded text does not leak and Any date skips projection coverage', + () async { + final db = AppDatabase.memoryForTests(); + addTearDown(db.close); + await _seedSearchDatabase(db); + var calls = 0; + final repo = ScheduleRepository( + db, + ensureProjectionCoverage: (_) async { + calls++; + }, + ); + final range = ScheduleRange.day(DateTime(2026, 1, 1)); + expect( + await repo.listItems( + range: range, + filters: const ScheduleFilters(query: 'future budget'), + ), + isEmpty, + ); + expect(calls, 1); + expect( + await repo.listItems( + range: range, + filters: const ScheduleFilters( + query: 'future budget', + ignoreDateRange: true, + ), + ), + hasLength(1), + ); + expect(calls, 1); + expect( + await repo.listItems( + range: ScheduleRange.day(DateTime(2026, 2, 15)), + filters: const ScheduleFilters( + query: 'future budget', + useTaskDueDate: true, + ), + ), + hasLength(1), + ); + }, + ); + test( + 'repository date filter includes spanning events and excludes boundary and outside events', + () async { + final db = AppDatabase.memoryForTests(); + addTearDown(db.close); + await _insertScheduleAccount(db, provider: BusyProvider.google); + final calendar = CalendarRepository(database: db); + await calendar.upsertSource( + accountId: 'account', + source: const CalendarSourceDto( + provider: BusyProvider.google, + providerCalendarId: 'calendar', + summary: 'Work', + ), + ); + for (final entry in [ + ('inside', '2026-06-12', '2026-06-13'), + ('overlap', '2026-06-11', '2026-06-14'), + ('outside', '2026-06-13', '2026-06-14'), + ('ends-at-start', '2026-06-11', '2026-06-12'), + ]) { + await calendar.upsertEvent( + accountId: 'account', + event: CalendarEventDto( + provider: BusyProvider.google, + providerCalendarId: 'calendar', + providerEventId: entry.$1, + title: entry.$1, + allDay: true, + startDate: entry.$2, + endDate: entry.$3, + ), + ); + } + final items = await ScheduleRepository(db).listItems( + range: ScheduleRange.day(DateTime(2026, 6, 12)), + filters: const ScheduleFilters(includeTasks: false), + ); + expect(items.map((i) => i.title).toSet(), {'inside', 'overlap'}); + }, + ); + test( 'search matches event title, location, description, and calendar name', () { @@ -75,7 +504,8 @@ void main() { filters: const ScheduleFilters( accountIds: {'account'}, query: 'future budget', - showCompletedTasks: true, + ignoreDateRange: true, + taskCompletion: ScheduleTaskCompletion.all, ), ); @@ -112,7 +542,7 @@ void main() { filters: const ScheduleFilters( accountIds: {'account'}, includeCalendarEvents: false, - showCompletedTasks: true, + taskCompletion: ScheduleTaskCompletion.all, showNoDateTasks: true, ), ); diff --git a/test/platform/linux_header_bar_service_test.dart b/test/platform/linux_header_bar_service_test.dart index 78310999..f94d85a6 100644 --- a/test/platform/linux_header_bar_service_test.dart +++ b/test/platform/linux_header_bar_service_test.dart @@ -615,7 +615,9 @@ void main() { TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .setMockMethodCallHandler(channel, (call) async { calls.add(call); - if (call.method == 'initialize' || call.method == 'focusSearch') { + if (call.method == 'initialize' || + call.method == 'focusSearch' || + call.method == 'focusContent') { return true; } return null; @@ -635,6 +637,9 @@ void main() { expect(await coveredSession.focusSearch(), isFalse); expect(await activeSession.focusSearch(), isTrue); expect(calls.where((call) => call.method == 'focusSearch'), hasLength(1)); + expect(await coveredSession.focusContent(), isFalse); + expect(await activeSession.focusContent(), isTrue); + expect(calls.where((call) => call.method == 'focusContent'), hasLength(1)); }); test('native search uses responsive GTK geometry with a scoped Yaru shim', () { From 62bc5684d14520f8110ed8d55d645c251b0d549e Mon Sep 17 00:00:00 2001 From: ChenZhenIO Date: Tue, 15 Sep 2026 13:42:48 -0700 Subject: [PATCH 09/25] Refactored Schedule search filters and person matching logic --- .../presentation/schedule_search_filters.dart | 41 ++----- .../schedule_search_result_text.dart | 22 +++- lib/src/schedule/schedule_search_match.dart | 37 ++++-- .../windows/windows_schedule_search_pane.dart | 2 +- .../schedule_search_filters_test.dart | 94 ++++++++++++++ .../schedule_search_result_text_test.dart | 115 ++++++++++++++++++ .../schedule/schedule_search_test.dart | 74 +++++++++++ 7 files changed, 337 insertions(+), 48 deletions(-) create mode 100644 test/features/schedule/presentation/schedule_search_filters_test.dart create mode 100644 test/features/schedule/presentation/schedule_search_result_text_test.dart diff --git a/lib/src/features/schedule/presentation/schedule_search_filters.dart b/lib/src/features/schedule/presentation/schedule_search_filters.dart index 389a55cf..ff765387 100644 --- a/lib/src/features/schedule/presentation/schedule_search_filters.dart +++ b/lib/src/features/schedule/presentation/schedule_search_filters.dart @@ -1,5 +1,4 @@ import 'package:flutter/material.dart'; -import 'package:intl/intl.dart'; import 'package:yaru/yaru.dart'; import '../../../app/busymax_design.dart'; @@ -9,6 +8,7 @@ import '../../../schedule/schedule_filters.dart'; import '../../accounts/data/accounts_repository.dart'; import '../../calendar/data/calendar_repository.dart'; import '../../task_lists/data/task_lists_repository.dart'; +import '../../tasks/presentation/desktop_date_time_fields.dart'; import 'schedule_search_labels.dart'; /// Compact Yaru search controls for the existing Schedule sidebar. @@ -54,18 +54,8 @@ class ScheduleSearchFilters extends StatelessWidget { (v) => onChanged(value.copyWith(date: v)), ), if (value.date == ScheduleSearchDate.custom) ...[ - _date( - context, - l.startDate, - value.customStart ?? value.referenceDate, - true, - ), - _date( - context, - l.endDate, - value.customEnd ?? value.referenceDate, - false, - ), + _date(l.startDate, value.customStart ?? value.referenceDate, true), + _date(l.endDate, value.customEnd ?? value.referenceDate, false), ], if (value.includesTasks) ...[ _select( @@ -187,22 +177,14 @@ class ScheduleSearchFilters extends StatelessWidget { child: _SearchFilterText(label: label, value: text, onChanged: changed), ); - Widget _date( - BuildContext context, - String label, - DateTime date, - bool start, - ) => Padding( + Widget _date(String label, DateTime date, bool start) => Padding( padding: const EdgeInsetsDirectional.only(bottom: 8), - child: OutlinedButton( - onPressed: () async { - final picked = await showDatePicker( - context: context, - initialDate: date, - firstDate: DateTime(1900), - lastDate: DateTime(2200), - ); - if (picked == null) return; + child: DesktopDateField( + label: label, + date: encodeDateOnly(date), + useNativePicker: true, + onChanged: (date) { + final picked = DateTime.parse(date); final from = start ? picked : value.customStart ?? value.referenceDate; final to = start ? value.customEnd ?? value.referenceDate : picked; onChanged( @@ -212,9 +194,6 @@ class ScheduleSearchFilters extends StatelessWidget { ), ); }, - child: Text( - '$label: ${DateFormat.yMMMd(Localizations.localeOf(context).toLanguageTag()).format(date)}', - ), ), ); } diff --git a/lib/src/features/schedule/presentation/schedule_search_result_text.dart b/lib/src/features/schedule/presentation/schedule_search_result_text.dart index 21f9b8ca..529e87f6 100644 --- a/lib/src/features/schedule/presentation/schedule_search_result_text.dart +++ b/lib/src/features/schedule/presentation/schedule_search_result_text.dart @@ -16,11 +16,27 @@ String scheduleSearchResultText( final l = context.l10n; final locale = Localizations.localeOf(context).toLanguageTag(); String date(DateTime value) => DateFormat.yMMMd(locale).format(value); + String taskDate(DateTime value) { + // A DAV task can have an all-day start and a separately timed due value. + final hasTime = + !item.allDay || + value.hour != 0 || + value.minute != 0 || + value.second != 0 || + value.millisecond != 0 || + value.microsecond != 0; + return hasTime + ? formatClockDateTime(context, value, date(value)) + : date(value); + } + final start = item.start; final time = item is TaskScheduleItem - ? item.due == null - ? l.searchNoDueDate - : '${l.dueDate}: ${date(item.due!)}' + ? item.due != null + ? '${l.dueDate}: ${taskDate(item.due!)}' + : start != null + ? '${l.startDate}: ${taskDate(start)}' + : l.searchNoDueDate : start == null ? l.noDate : item.allDay diff --git a/lib/src/schedule/schedule_search_match.dart b/lib/src/schedule/schedule_search_match.dart index 528edc4b..eae1a5ce 100644 --- a/lib/src/schedule/schedule_search_match.dart +++ b/lib/src/schedule/schedule_search_match.dart @@ -13,11 +13,12 @@ List _terms(String query) => query /// Only identity fields are searchable; response and other provider metadata /// deliberately do not become part of a person's identity. -Iterable scheduleEventPeople(CalendarScheduleItem item) sync* { +Iterable> scheduleEventPeople(CalendarScheduleItem item) sync* { for (final person in [ if (item.organizer != null) item.organizer!, ...item.attendees, ]) { + final identity = []; for (final key in const [ 'displayName', 'name', @@ -27,18 +28,24 @@ Iterable scheduleEventPeople(CalendarScheduleItem item) sync* { 'uri', ]) { final value = person[key]; - if (value is String && value.trim().isNotEmpty) yield value; + if (value is String && value.trim().isNotEmpty) identity.add(value); } final nested = person['emailAddress']; if (nested is Map) { for (final key in const ['name', 'address']) { final value = nested[key]; - if (value is String && value.trim().isNotEmpty) yield value; + if (value is String && value.trim().isNotEmpty) identity.add(value); } } + if (identity.isNotEmpty) yield identity; } } +bool _matchesPerson(List identity, List terms) { + final fields = identity.map((field) => field.toLowerCase()).toList(); + return terms.every((term) => fields.any((field) => field.contains(term))); +} + String? scheduleItemLocation(ScheduleItem item) => switch (item) { CalendarScheduleItem() => item.location, TaskScheduleItem() => item.location, @@ -53,7 +60,11 @@ String _plainDescription(ScheduleItem item) => switch (item) { Iterable _contextFields(ScheduleItem item) sync* { yield scheduleItemLocation(item) ?? ''; - if (item is CalendarScheduleItem) yield* scheduleEventPeople(item); + if (item is CalendarScheduleItem) { + for (final identity in scheduleEventPeople(item)) { + yield* identity; + } + } yield _plainDescription(item); yield* item.categories; if (item is TaskScheduleItem) { @@ -110,12 +121,11 @@ bool matchesScheduleFilters( return false; } } - final people = scheduleEventPeople( - item, - ).map((s) => s.toLowerCase()).toList(); - if (!_terms( - filters.person, - ).every((term) => people.any((s) => s.contains(term)))) { + final personTerms = _terms(filters.person); + if (personTerms.isNotEmpty && + !scheduleEventPeople( + item, + ).any((identity) => _matchesPerson(identity, personTerms))) { return false; } } @@ -171,9 +181,10 @@ String? scheduleMatchContext( return _snippet(scheduleItemLocation(item)!, _terms(location)); } if (person.trim().isNotEmpty && item is CalendarScheduleItem) { + final personTerms = _terms(person); for (final identity in scheduleEventPeople(item)) { - if (_terms(person).any(identity.toLowerCase().contains)) { - return _snippet(identity, _terms(person)); + if (_matchesPerson(identity, personTerms)) { + return _snippet(identity.join(' · '), personTerms); } } } @@ -194,7 +205,7 @@ String _snippet(String field, List terms) { ..sort(); final start = matches.isEmpty ? 0 - : (matches.first - 35).clamp(0, text.length - 140); + : (matches.first - 35).clamp(0, text.length - 140).toInt(); return '${start > 0 ? '…' : ''}${text.substring(start, start + 140)}…'; } diff --git a/lib/src/ui/windows/windows_schedule_search_pane.dart b/lib/src/ui/windows/windows_schedule_search_pane.dart index 0667d8f4..5b3c2b9e 100644 --- a/lib/src/ui/windows/windows_schedule_search_pane.dart +++ b/lib/src/ui/windows/windows_schedule_search_pane.dart @@ -169,7 +169,7 @@ class WindowsScheduleSearchPane extends StatelessWidget { checked: checked, onChanged: onChanged, content: SizedBox( - width: (constraints.maxWidth - 36).clamp(0, double.infinity), + width: (constraints.maxWidth - 36).clamp(0, double.infinity).toDouble(), child: Row( children: [ Icon(windowsBusyMaxGlyph(glyph), size: 16), diff --git a/test/features/schedule/presentation/schedule_search_filters_test.dart b/test/features/schedule/presentation/schedule_search_filters_test.dart new file mode 100644 index 00000000..16309405 --- /dev/null +++ b/test/features/schedule/presentation/schedule_search_filters_test.dart @@ -0,0 +1,94 @@ +import 'package:busymax/l10n/generated/app_localizations.dart'; +import 'package:busymax/src/features/schedule/presentation/schedule_search_filters.dart'; +import 'package:busymax/src/features/tasks/presentation/desktop_date_time_fields.dart'; +import 'package:busymax/src/schedule/schedule_search_criteria.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:yaru/yaru.dart'; + +import '../../../test_localized_app.dart'; + +void main() { + testWidgets('Linux custom range uses native desktop date fields', ( + tester, + ) async { + const channel = MethodChannel(nativeDateTimePickerChannelName); + final calls = []; + final responses = ['2026-06-15', '2026-06-12', null]; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + calls.add(call); + return responses.removeAt(0); + }); + addTearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); + }); + + var criteria = ScheduleSearchCriteria( + referenceDate: DateTime(2026, 6, 12), + firstWeekday: DateTime.monday, + sourceIds: {}, + taskListKeys: {}, + date: ScheduleSearchDate.custom, + customStart: DateTime(2026, 6, 12), + customEnd: DateTime(2026, 6, 14), + ); + await tester.pumpWidget( + localizedTestApp( + child: Scaffold( + body: SizedBox( + width: 320, + child: StatefulBuilder( + builder: (context, setState) => ScheduleSearchFilters( + value: criteria, + onChanged: (value) => setState(() => criteria = value), + onClear: () {}, + accounts: const [], + sources: const [], + taskLists: const [], + ), + ), + ), + ), + ), + ); + expect(find.byType(DesktopDateField), findsNWidgets(2)); + final labels = AppLocalizations.of( + tester.element(find.byType(ScheduleSearchFilters)), + ); + + Future pickDate(String label) async { + final field = find.byWidgetPredicate( + (widget) => widget is DesktopDateField && widget.label == label, + ); + final button = find.descendant( + of: field, + matching: find.byType(YaruIconButton), + ); + await tester.ensureVisible(button); + await tester.tap(button); + await tester.pumpAndSettle(); + } + + await pickDate(labels.startDate); + expect(calls.single.method, 'pickDate'); + expect(calls.single.arguments, containsPair('initialDate', '2026-06-12')); + expect(criteria.customStart, DateTime(2026, 6, 15)); + expect(criteria.customEnd, DateTime(2026, 6, 15)); + expect(criteria.range!.end, DateTime(2026, 6, 16)); + + await pickDate(labels.endDate); + expect(calls.last.arguments, containsPair('initialDate', '2026-06-15')); + expect(criteria.customStart, DateTime(2026, 6, 12)); + expect(criteria.customEnd, DateTime(2026, 6, 12)); + expect(criteria.range!.end, DateTime(2026, 6, 13)); + + final beforeCancel = criteria; + await pickDate(labels.endDate); + expect(criteria, beforeCancel); + expect(find.byType(DatePickerDialog), findsNothing); + expect(tester.takeException(), isNull); + }); +} diff --git a/test/features/schedule/presentation/schedule_search_result_text_test.dart b/test/features/schedule/presentation/schedule_search_result_text_test.dart new file mode 100644 index 00000000..8fe2c298 --- /dev/null +++ b/test/features/schedule/presentation/schedule_search_result_text_test.dart @@ -0,0 +1,115 @@ +import 'package:busymax/src/features/schedule/presentation/schedule_search_result_text.dart'; +import 'package:busymax/src/providers/busy_provider.dart'; +import 'package:busymax/src/schedule/schedule_item.dart'; +import 'package:busymax/src/schedule/schedule_search_criteria.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../../test_localized_app.dart'; + +void main() { + final criteria = ScheduleSearchCriteria( + referenceDate: DateTime(2026, 6, 12), + firstWeekday: DateTime.monday, + sourceIds: {}, + taskListKeys: {}, + ); + TaskScheduleItem task({ + DateTime? due, + DateTime? start, + bool allDay = false, + BusyProvider provider = BusyProvider.microsoft, + }) => TaskScheduleItem( + id: 'task', + accountId: 'account', + sourceId: 'list', + provider: provider, + title: 'Report', + completed: false, + allDay: allDay, + due: due, + start: start, + sourceName: 'Work', + accountEmail: 'alex@example.com', + ); + + for (final (name, item, dateLabel) in [ + ( + 'date-only due', + task(due: DateTime(2026, 6, 12), allDay: true), + 'Due date: Jun 12, 2026', + ), + ( + 'timed due takes precedence over start', + task(due: DateTime(2026, 6, 12, 15, 45), start: DateTime(2026, 6, 10, 9)), + 'Due date: Jun 12, 2026 · 15:45', + ), + ( + 'timed due remains visible with an all-day start', + task( + due: DateTime(2026, 6, 12, 15, 45), + start: DateTime(2026, 6, 10), + allDay: true, + provider: BusyProvider.nextcloud, + ), + 'Due date: Jun 12, 2026 · 15:45', + ), + ( + 'timed midnight due', + task(due: DateTime(2026, 6, 12)), + 'Due date: Jun 12, 2026 · 00:00', + ), + ( + 'Microsoft start without due', + task(start: DateTime(2026, 6, 10, 9, 30)), + 'Start date: Jun 10, 2026 · 09:30', + ), + ( + 'DAV start without due', + task( + start: DateTime(2026, 6, 10, 9, 30), + provider: BusyProvider.nextcloud, + ), + 'Start date: Jun 10, 2026 · 09:30', + ), + ( + 'all-day start without due', + task(start: DateTime(2026, 6, 10), allDay: true), + 'Start date: Jun 10, 2026', + ), + ('neither due nor start', task(), 'No due date'), + ]) { + testWidgets('task result shows $name', (tester) async { + await tester.pumpWidget( + localizedTestApp( + alwaysUse24HourFormat: true, + child: Builder( + builder: (context) => + Text(scheduleSearchResultText(context, item, criteria, '')), + ), + ), + ); + expect(find.text('$dateLabel · Work · alex@example.com'), findsOneWidget); + }); + } + testWidgets('task due time respects the 12-hour clock preference', ( + tester, + ) async { + await tester.pumpWidget( + localizedTestApp( + alwaysUse24HourFormat: false, + child: Builder( + builder: (context) => Text( + scheduleSearchResultText( + context, + task(due: DateTime(2026, 6, 12, 15, 45)), + criteria, + '', + ), + ), + ), + ), + ); + expect(find.textContaining('3:45 PM'), findsOneWidget); + }); +} diff --git a/test/features/schedule/schedule_search_test.dart b/test/features/schedule/schedule_search_test.dart index a90a094d..bbd65251 100644 --- a/test/features/schedule/schedule_search_test.dart +++ b/test/features/schedule/schedule_search_test.dart @@ -81,6 +81,80 @@ void main() { expect(matches(task(), ScheduleFilters(person: term)), isFalse); }); } + for (final nested in [false, true]) { + test('Person terms must identify one attendee (nested: $nested)', () { + Map identity(String name, String email) => nested + ? { + 'emailAddress': {'name': name, 'address': email}, + } + : {'displayName': name, 'email': email}; + final participants = CalendarScheduleItem( + id: 'participants', + accountId: 'account', + provider: BusyProvider.microsoft, + sourceId: 'calendar', + providerCalendarId: 'calendar', + title: 'Meeting', + allDay: true, + attendees: [ + identity('Alex Jones', 'alex@example.com'), + identity('Robin Smith', 'robin@example.com'), + ], + organizer: identity('Morgan Lee', 'morgan@example.com'), + ); + bool personMatches(String person) => matches( + participants, + ScheduleFilters(ignoreDateRange: true, person: person), + ); + expect(personMatches('Alex Smith'), isFalse); + expect(personMatches('Morgan Jones'), isFalse); + expect(personMatches('Alex robin@example.com'), isFalse); + expect(personMatches('ALEX Jones'), isTrue); + expect(personMatches('Alex alex@example.com'), isTrue); + expect(personMatches('Morgan morgan@example.com'), isTrue); + // Free text deliberately keeps its cross-field matching semantics. + expect(matchesScheduleQuery(participants, 'Alex Smith'), isTrue); + }); + } + test('Person context describes the matching participant', () { + const participants = CalendarScheduleItem( + id: 'participants', + accountId: 'account', + provider: BusyProvider.microsoft, + sourceId: 'calendar', + providerCalendarId: 'calendar', + title: 'Meeting', + allDay: true, + organizer: {'name': 'Alex Jones', 'email': 'jones@example.com'}, + attendees: [ + {'name': 'Alex Smith', 'email': 'smith@example.com'}, + ], + ); + final snippet = scheduleMatchContext( + participants, + '', + person: 'Alex Smith', + ); + expect(snippet, contains('Alex Smith')); + expect(snippet, isNot(contains('Jones'))); + }); + test('long snippets include the match within bounded text', () { + for (final padding in [0, 200]) { + final item = TaskScheduleItem( + id: 'long-notes', + accountId: 'account', + provider: BusyProvider.microsoft, + sourceId: 'list', + title: 'Report', + completed: false, + allDay: true, + notes: '${'x' * padding} needle ${'y' * 200}', + ); + final snippet = scheduleMatchContext(item, 'needle')!; + expect(snippet, contains('needle')); + expect(snippet.length, lessThanOrEqualTo(142)); + } + }); test( 'identity extraction ignores response metadata and handles organizer emailAddress', () { From 40726ce1fcb570204ec0713abbbfcb426df597e4 Mon Sep 17 00:00:00 2001 From: ChenZhenIO Date: Tue, 15 Sep 2026 15:08:06 -0700 Subject: [PATCH 10/25] Refactor filters ui --- .../presentation/schedule_search_filters.dart | 234 ---- .../presentation/schedule_workspace.dart | 245 +++- .../linux_schedule_search_filter_service.dart | 566 ++++++++ linux/runner/my_application.cc | 1219 ++++++++++++++++- test/app/native_ui_audit_test.dart | 50 + .../schedule_search_filters_test.dart | 94 -- .../schedule_search_platform_test.dart | 229 +++- .../schedule_workspace_states_test.dart | 56 +- ...x_schedule_search_filter_service_test.dart | 272 ++++ 9 files changed, 2504 insertions(+), 461 deletions(-) delete mode 100644 lib/src/features/schedule/presentation/schedule_search_filters.dart create mode 100644 lib/src/platform/linux_schedule_search_filter_service.dart delete mode 100644 test/features/schedule/presentation/schedule_search_filters_test.dart create mode 100644 test/platform/linux_schedule_search_filter_service_test.dart diff --git a/lib/src/features/schedule/presentation/schedule_search_filters.dart b/lib/src/features/schedule/presentation/schedule_search_filters.dart deleted file mode 100644 index ff765387..00000000 --- a/lib/src/features/schedule/presentation/schedule_search_filters.dart +++ /dev/null @@ -1,234 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:yaru/yaru.dart'; -import '../../../app/busymax_design.dart'; - -import '../../../l10n/l10n.dart'; -import '../../../schedule/schedule_search_criteria.dart'; -import '../../../schedule/schedule_filters.dart'; -import '../../accounts/data/accounts_repository.dart'; -import '../../calendar/data/calendar_repository.dart'; -import '../../task_lists/data/task_lists_repository.dart'; -import '../../tasks/presentation/desktop_date_time_fields.dart'; -import 'schedule_search_labels.dart'; - -/// Compact Yaru search controls for the existing Schedule sidebar. -/// This widget only emits immutable criteria; it never writes source settings. -class ScheduleSearchFilters extends StatelessWidget { - const ScheduleSearchFilters({ - super.key, - required this.value, - required this.onChanged, - required this.onClear, - required this.accounts, - required this.sources, - required this.taskLists, - }); - final ScheduleSearchCriteria value; - final ValueChanged onChanged; - final VoidCallback onClear; - final List accounts; - final List sources; - final List taskLists; - - @override - Widget build(BuildContext context) { - final l = context.l10n; - return ListView( - key: const ValueKey('schedule-search-filters'), - padding: const EdgeInsetsDirectional.all(16), - children: [ - Text(l.searchFilters, style: Theme.of(context).textTheme.titleMedium), - const SizedBox(height: 16), - _select( - l.searchType, - value.type, - ScheduleSearchType.values, - (v) => searchTypeLabel(l, v), - (v) => onChanged(value.copyWith(type: v)), - ), - _select( - l.searchDate, - value.date, - ScheduleSearchDate.values, - (v) => searchDateLabel(l, v), - (v) => onChanged(value.copyWith(date: v)), - ), - if (value.date == ScheduleSearchDate.custom) ...[ - _date(l.startDate, value.customStart ?? value.referenceDate, true), - _date(l.endDate, value.customEnd ?? value.referenceDate, false), - ], - if (value.includesTasks) ...[ - _select( - l.searchTaskStatus, - value.taskCompletion, - ScheduleTaskCompletion.values, - (v) => searchCompletionLabel(l, v), - (v) => onChanged(value.copyWith(taskCompletion: v)), - ), - _select( - l.searchTaskDue, - value.taskDueState, - ScheduleTaskDueState.values, - (v) => searchDueLabel(l, v), - (v) => onChanged(value.copyWith(taskDueState: v)), - ), - ], - if (value.includesEvents) - _text( - l.searchPerson, - value.person, - (v) => onChanged(value.copyWith(person: v)), - ), - _text( - l.location, - value.location, - (v) => onChanged(value.copyWith(location: v)), - ), - Text(l.searchSources, style: Theme.of(context).textTheme.titleSmall), - for (final account in accounts) ...[ - Padding( - padding: const EdgeInsetsDirectional.only(top: 12, bottom: 4), - child: Text( - account.displayLabel, - style: Theme.of(context).textTheme.labelLarge, - ), - ), - if (value.includesEvents) - for (final source in sources.where( - (s) => s.accountId == account.id && !s.isDeleted, - )) - YaruCheckboxListTile( - contentPadding: EdgeInsets.zero, - controlAffinity: ListTileControlAffinity.leading, - title: Text(source.summary), - secondary: const Icon(Icons.calendar_month_outlined, size: 18), - value: value.sourceIds.contains(source.id), - onChanged: (selected) { - final ids = {...value.sourceIds}; - if (selected == true) { - ids.add(source.id); - } else { - ids.remove(source.id); - } - onChanged(value.copyWith(sourceIds: ids)); - }, - ), - if (value.includesTasks) - for (final list in taskLists.where( - (s) => s.accountId == account.id && !s.pendingDelete, - )) - YaruCheckboxListTile( - contentPadding: EdgeInsets.zero, - controlAffinity: ListTileControlAffinity.leading, - title: Text(list.title), - secondary: const Icon(Icons.checklist, size: 18), - value: value.taskListKeys.contains( - ScheduleTaskListKey( - accountId: list.accountId, - taskListId: list.id, - ), - ), - onChanged: (selected) { - final keys = {...value.taskListKeys}; - final key = ScheduleTaskListKey( - accountId: list.accountId, - taskListId: list.id, - ); - if (selected == true) { - keys.add(key); - } else { - keys.remove(key); - } - onChanged(value.copyWith(taskListKeys: keys)); - }, - ), - ], - if (!value.hasSources) - Padding( - padding: const EdgeInsetsDirectional.symmetric(vertical: 12), - child: Text(l.searchNoSources), - ), - const SizedBox(height: 12), - TextButton(onPressed: onClear, child: Text(l.searchClearFilters)), - ], - ); - } - - Widget _select( - String label, - T selected, - List values, - String Function(T) name, - ValueChanged changed, - ) => Padding( - padding: const EdgeInsetsDirectional.only(bottom: 12), - child: BusyMaxComboRow( - title: label, - values: values, - selected: selected, - labelFor: name, - onSelected: changed, - ), - ); - - Widget _text(String label, String text, ValueChanged changed) => - Padding( - padding: const EdgeInsetsDirectional.only(bottom: 16), - child: _SearchFilterText(label: label, value: text, onChanged: changed), - ); - - Widget _date(String label, DateTime date, bool start) => Padding( - padding: const EdgeInsetsDirectional.only(bottom: 8), - child: DesktopDateField( - label: label, - date: encodeDateOnly(date), - useNativePicker: true, - onChanged: (date) { - final picked = DateTime.parse(date); - final from = start ? picked : value.customStart ?? value.referenceDate; - final to = start ? value.customEnd ?? value.referenceDate : picked; - onChanged( - value.copyWith( - customStart: from.isAfter(to) ? picked : from, - customEnd: to.isBefore(from) ? picked : to, - ), - ); - }, - ), - ); -} - -class _SearchFilterText extends StatefulWidget { - const _SearchFilterText({ - required this.label, - required this.value, - required this.onChanged, - }); - final String label; - final String value; - final ValueChanged onChanged; - @override - State<_SearchFilterText> createState() => _SearchFilterTextState(); -} - -class _SearchFilterTextState extends State<_SearchFilterText> { - late final _controller = TextEditingController(text: widget.value); - @override - void didUpdateWidget(covariant _SearchFilterText oldWidget) { - super.didUpdateWidget(oldWidget); - if (_controller.text != widget.value) _controller.text = widget.value; - } - - @override - void dispose() { - _controller.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) => TextField( - controller: _controller, - decoration: InputDecoration(labelText: widget.label), - onChanged: widget.onChanged, - ); -} diff --git a/lib/src/features/schedule/presentation/schedule_workspace.dart b/lib/src/features/schedule/presentation/schedule_workspace.dart index d8908105..393f081a 100644 --- a/lib/src/features/schedule/presentation/schedule_workspace.dart +++ b/lib/src/features/schedule/presentation/schedule_workspace.dart @@ -27,6 +27,7 @@ import '../../../l10n/l10n.dart'; import '../../../l10n/localized_formatters.dart'; import '../../../platform/linux_header_bar_service.dart'; import '../../../platform/linux_header_bar_provider.dart'; +import '../../../platform/linux_schedule_search_filter_service.dart'; import '../../../schedule/schedule_commands.dart'; import '../../../schedule/schedule_filters.dart'; import '../../../schedule/schedule_item.dart'; @@ -60,7 +61,6 @@ import 'schedule_item_exporter.dart'; import 'schedule_item_selection.dart'; import 'schedule_month_view.dart'; import 'schedule_sidebar.dart'; -import 'schedule_search_filters.dart'; import '../../../schedule/schedule_search_criteria.dart'; import 'schedule_toolbar.dart'; import 'schedule_year_view.dart'; @@ -179,8 +179,10 @@ class _ScheduleWorkspaceState extends ConsumerState { late ScheduleScope _scope; _TaskDetailsTarget? _taskDetailsTarget; late final LinuxHeaderBarSession _headerBarSession; + late final LinuxScheduleSearchFilterSession _searchFilterSession; StreamSubscription? _headerBarActions; StreamSubscription? _headerBarSearchEvents; + StreamSubscription? _searchFilterEvents; var _headerBarReady = false; var _nativeHeaderBarAvailable = false; var _sidebarCollapsed = false; @@ -189,8 +191,6 @@ class _ScheduleWorkspaceState extends ConsumerState { bool? _sidebarBeforeSearch; ScheduleSearchCriteria? _searchCriteria; ScheduleSearchCriteria? _initialSearchCriteria; - List _searchSources = const []; - List _searchTaskLists = const []; ScheduleSourceVisibility? _searchVisibility; final _searchController = TextEditingController(); final _workspaceFocusNode = FocusNode(); @@ -235,6 +235,13 @@ class _ScheduleWorkspaceState extends ConsumerState { _headerBarSearchEvents = _headerBarSession.searchEvents.listen( _handleHeaderBarSearchEvent, ); + _searchFilterSession = ref + .read(linuxScheduleSearchFilterServiceProvider) + .claimSession(); + _searchFilterEvents = _searchFilterSession.events.listen( + _handleNativeSearchFilterEvent, + ); + unawaited(_searchFilterSession.initialize()); unawaited(_initializeHeaderBar()); _scheduleInitialTaskWatch(); } @@ -247,6 +254,7 @@ class _ScheduleWorkspaceState extends ConsumerState { unawaited(createChoiceMenuSession.dismiss()); } _headerBarSession.dispose(); + _searchFilterSession.dispose(); if (_taskDetailsTarget != null) { unawaited( releaseBusyMaxModalBarrier(ref.read(linuxHeaderBarServiceProvider)), @@ -254,6 +262,7 @@ class _ScheduleWorkspaceState extends ConsumerState { } unawaited(_headerBarActions?.cancel()); unawaited(_headerBarSearchEvents?.cancel()); + unawaited(_searchFilterEvents?.cancel()); unawaited(_initialTaskTargetSubscription?.cancel()); _searchController.dispose(); _workspaceFocusNode.dispose(); @@ -278,6 +287,7 @@ class _ScheduleWorkspaceState extends ConsumerState { @override Widget build(BuildContext context) { + final routeIsCurrent = ModalRoute.of(context)?.isCurrent ?? true; final settings = ref.watch(appSettingsControllerProvider); _syncModeFromSettings(settings.scheduleViewMode); final range = _range(context); @@ -321,8 +331,6 @@ class _ScheduleWorkspaceState extends ConsumerState { taskLists: taskLists, settings: settings, ); - _searchSources = sources; - _searchTaskLists = taskLists; _searchVisibility = visibility; if (_searchActive && _searchCriteria == null && @@ -444,9 +452,9 @@ class _ScheduleWorkspaceState extends ConsumerState { ); Widget buildSidebar() { if (searchActive && _searchCriteria != null) { - return SizedBox( + return const SizedBox( + key: ValueKey('linux-native-search-filter-spacer'), width: BusyMaxSizes.sidebarWidth, - child: _searchFilterPanel(), ); } return SizedBox( @@ -501,7 +509,7 @@ class _ScheduleWorkspaceState extends ConsumerState { YaruIconButton( tooltip: context.l10n.searchFiltersAction, icon: const Icon(Icons.filter_list), - onPressed: _showSearchFilters, + onPressed: _showNativeSearchFilters, ), YaruIconButton( tooltip: context.l10n.close, @@ -679,6 +687,14 @@ class _ScheduleWorkspaceState extends ConsumerState { canCreateTask: canCreateTask, showSidebar: showSidebar, ); + _updateNativeSearchFilterState( + context, + accounts: accounts, + sources: sources, + taskLists: taskLists, + showSidebar: showSidebar, + routeIsCurrent: routeIsCurrent, + ); final body = !showSidebar || _sidebarCollapsed ? main : Row( @@ -803,6 +819,161 @@ class _ScheduleWorkspaceState extends ConsumerState { }); } + void _updateNativeSearchFilterState( + BuildContext context, { + required List accounts, + required List sources, + required List taskLists, + required bool showSidebar, + required bool routeIsCurrent, + }) { + final l = context.l10n; + final material = MaterialLocalizations.of(context); + final criteria = _searchCriteria; + final state = LinuxScheduleSearchFilterState( + active: routeIsCurrent && _searchActive && criteria != null, + sidebarVisible: + routeIsCurrent && + _searchActive && + criteria != null && + showSidebar && + !_sidebarCollapsed, + sidebarWidth: BusyMaxSizes.sidebarWidth, + criteria: criteria, + labels: LinuxScheduleSearchFilterLabels( + searchFilters: l.searchFilters, + type: l.searchType, + all: l.all, + events: l.calendarEvents, + tasks: l.tasks, + date: l.searchDate, + anyDate: l.searchAnyDate, + today: l.today, + tomorrow: l.tomorrow, + thisWeek: l.searchThisWeek, + customRange: l.searchCustomRange, + startDate: l.startDate, + endDate: l.endDate, + taskStatus: l.searchTaskStatus, + open: l.openStatus, + completed: l.completed, + taskDue: l.searchTaskDue, + anyDueState: l.searchAnyDueState, + overdue: l.overdue, + noDueDate: l.searchNoDueDate, + person: l.searchPerson, + location: l.location, + sources: l.searchSources, + clearFilters: l.searchClearFilters, + noSources: l.searchNoSources, + close: l.close, + cancel: material.cancelButtonLabel, + ok: material.okButtonLabel, + ), + accounts: [ + for (final account in accounts) + LinuxScheduleSearchFilterAccount( + id: account.id, + label: account.displayLabel, + ), + ], + calendarSources: [ + for (final source in sources) + if (!source.isDeleted) + LinuxScheduleSearchFilterCalendarSource( + id: source.id, + accountId: source.accountId, + title: source.summary, + selected: criteria?.sourceIds.contains(source.id) ?? false, + ), + ], + taskLists: [ + for (final taskList in taskLists) + if (!taskList.pendingDelete) + LinuxScheduleSearchFilterTaskList( + accountId: taskList.accountId, + taskListId: taskList.id, + title: taskList.title, + selected: + criteria?.taskListKeys.contains( + ScheduleTaskListKey( + accountId: taskList.accountId, + taskListId: taskList.id, + ), + ) ?? + false, + ), + ], + ); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + unawaited(_searchFilterSession.updateState(state)); + }); + } + + void _handleNativeSearchFilterEvent(LinuxScheduleSearchFilterEvent event) { + if (!_searchFilterSession.isCurrent || + !_searchActive || + !(ModalRoute.of(context)?.isCurrent ?? true)) { + return; + } + final criteria = _searchCriteria; + if (criteria == null) return; + switch (event) { + case LinuxScheduleSearchTypeChanged(:final value): + setState(() => _searchCriteria = criteria.copyWith(type: value)); + case LinuxScheduleSearchDateChanged(:final value): + setState(() => _searchCriteria = criteria.copyWith(date: value)); + case LinuxScheduleSearchTaskCompletionChanged(:final value): + setState( + () => _searchCriteria = criteria.copyWith(taskCompletion: value), + ); + case LinuxScheduleSearchTaskDueStateChanged(:final value): + setState( + () => _searchCriteria = criteria.copyWith(taskDueState: value), + ); + case LinuxScheduleSearchPersonChanged(:final value): + setState(() => _searchCriteria = criteria.copyWith(person: value)); + case LinuxScheduleSearchLocationChanged(:final value): + setState(() => _searchCriteria = criteria.copyWith(location: value)); + case LinuxScheduleSearchCustomStartChanged(:final value): + final end = criteria.customEnd ?? criteria.referenceDate; + setState( + () => _searchCriteria = criteria.copyWith( + customStart: value, + customEnd: end.isBefore(value) ? value : end, + ), + ); + case LinuxScheduleSearchCustomEndChanged(:final value): + final start = criteria.customStart ?? criteria.referenceDate; + setState( + () => _searchCriteria = criteria.copyWith( + customStart: start.isAfter(value) ? value : start, + customEnd: value, + ), + ); + case LinuxScheduleSearchCalendarSourceToggled( + :final sourceId, + :final selected, + ): + final sourceIds = {...criteria.sourceIds}; + selected ? sourceIds.add(sourceId) : sourceIds.remove(sourceId); + setState( + () => _searchCriteria = criteria.copyWith(sourceIds: sourceIds), + ); + case LinuxScheduleSearchTaskListToggled(:final key, :final selected): + final taskListKeys = {...criteria.taskListKeys}; + selected ? taskListKeys.add(key) : taskListKeys.remove(key); + setState( + () => _searchCriteria = criteria.copyWith(taskListKeys: taskListKeys), + ); + case LinuxScheduleSearchClearRequested(): + setState(() => _searchCriteria = _initialSearchCriteria); + case LinuxScheduleSearchDismissRequested(): + _closeSearch(); + } + } + void _handleFallbackToolbarMenu(ScheduleToolbarMenuAction action) { switch (action) { case ScheduleToolbarMenuAction.refresh: @@ -889,7 +1060,7 @@ class _ScheduleWorkspaceState extends ConsumerState { return; case BusyMaxHeaderBarAction.sidebarToggle: if (!_latestCanShowSidebar) { - if (_searchActive) unawaited(_showSearchFilters()); + if (_searchActive) unawaited(_showNativeSearchFilters()); return; } setState(() => _sidebarCollapsed = !_sidebarCollapsed); @@ -1037,61 +1208,9 @@ class _ScheduleWorkspaceState extends ConsumerState { }); } - void _focusSearchFilters() { - if (_nativeHeaderBarAvailable) unawaited(_headerBarSession.focusContent()); - } - - Widget _searchFilterPanel({VoidCallback? refresh}) => Listener( - onPointerDown: (_) => _focusSearchFilters(), - child: Focus( - onFocusChange: (focused) { - if (focused) _focusSearchFilters(); - }, - child: ScheduleSearchFilters( - value: _searchCriteria!, - accounts: _latestAccounts, - sources: _searchSources, - taskLists: _searchTaskLists, - onChanged: (value) { - setState(() => _searchCriteria = value); - refresh?.call(); - }, - onClear: () { - setState(() => _searchCriteria = _initialSearchCriteria); - refresh?.call(); - }, - ), - ), - ); - - Future _showSearchFilters() async { + Future _showNativeSearchFilters() async { if (_searchCriteria == null) return; - await showDialog( - context: context, - builder: (context) => StatefulBuilder( - builder: (context, update) => Dialog( - child: SizedBox( - width: 360, - height: MediaQuery.sizeOf(context).height * .85, - child: Column( - children: [ - Align( - alignment: AlignmentDirectional.centerEnd, - child: YaruIconButton( - tooltip: context.l10n.close, - icon: const Icon(Icons.close), - onPressed: () => Navigator.pop(context), - ), - ), - Expanded( - child: _searchFilterPanel(refresh: () => update(() {})), - ), - ], - ), - ), - ), - ), - ); + await _searchFilterSession.showModal(); } Future<_ScheduleItemsResult> _scheduleItems({ diff --git a/lib/src/platform/linux_schedule_search_filter_service.dart b/lib/src/platform/linux_schedule_search_filter_service.dart new file mode 100644 index 00000000..5eb4ee46 --- /dev/null +++ b/lib/src/platform/linux_schedule_search_filter_service.dart @@ -0,0 +1,566 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:collection/collection.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../schedule/schedule_filters.dart'; +import '../schedule/schedule_search_criteria.dart'; + +@visibleForTesting +const linuxScheduleSearchFilterChannelName = + 'busymax/native_schedule_search_filters'; + +@immutable +final class LinuxScheduleSearchFilterLabels { + const LinuxScheduleSearchFilterLabels({ + required this.searchFilters, + required this.type, + required this.all, + required this.events, + required this.tasks, + required this.date, + required this.anyDate, + required this.today, + required this.tomorrow, + required this.thisWeek, + required this.customRange, + required this.startDate, + required this.endDate, + required this.taskStatus, + required this.open, + required this.completed, + required this.taskDue, + required this.anyDueState, + required this.overdue, + required this.noDueDate, + required this.person, + required this.location, + required this.sources, + required this.clearFilters, + required this.noSources, + required this.close, + required this.cancel, + required this.ok, + }); + + final String searchFilters; + final String type; + final String all; + final String events; + final String tasks; + final String date; + final String anyDate; + final String today; + final String tomorrow; + final String thisWeek; + final String customRange; + final String startDate; + final String endDate; + final String taskStatus; + final String open; + final String completed; + final String taskDue; + final String anyDueState; + final String overdue; + final String noDueDate; + final String person; + final String location; + final String sources; + final String clearFilters; + final String noSources; + final String close; + final String cancel; + final String ok; + + Map toJson() => { + 'searchFilters': searchFilters, + 'type': type, + 'all': all, + 'events': events, + 'tasks': tasks, + 'date': date, + 'anyDate': anyDate, + 'today': today, + 'tomorrow': tomorrow, + 'thisWeek': thisWeek, + 'customRange': customRange, + 'startDate': startDate, + 'endDate': endDate, + 'taskStatus': taskStatus, + 'open': open, + 'completed': completed, + 'taskDue': taskDue, + 'anyDueState': anyDueState, + 'overdue': overdue, + 'noDueDate': noDueDate, + 'person': person, + 'location': location, + 'sources': sources, + 'clearFilters': clearFilters, + 'noSources': noSources, + 'close': close, + 'cancel': cancel, + 'ok': ok, + }; +} + +@immutable +final class LinuxScheduleSearchFilterAccount { + const LinuxScheduleSearchFilterAccount({ + required this.id, + required this.label, + }); + + final String id; + final String label; + + Map toJson() => {'id': id, 'label': label}; +} + +@immutable +final class LinuxScheduleSearchFilterCalendarSource { + const LinuxScheduleSearchFilterCalendarSource({ + required this.id, + required this.accountId, + required this.title, + required this.selected, + }); + + final String id; + final String accountId; + final String title; + final bool selected; + + Map toJson() => { + 'id': id, + 'accountId': accountId, + 'title': title, + 'selected': selected, + }; +} + +@immutable +final class LinuxScheduleSearchFilterTaskList { + const LinuxScheduleSearchFilterTaskList({ + required this.accountId, + required this.taskListId, + required this.title, + required this.selected, + }); + + final String accountId; + final String taskListId; + final String title; + final bool selected; + + Map toJson() => { + 'accountId': accountId, + 'taskListId': taskListId, + 'title': title, + 'selected': selected, + }; +} + +@immutable +final class LinuxScheduleSearchFilterState { + const LinuxScheduleSearchFilterState({ + required this.active, + required this.sidebarVisible, + required this.sidebarWidth, + required this.criteria, + required this.labels, + required this.accounts, + required this.calendarSources, + required this.taskLists, + }); + + static const int schemaVersion = 1; + + final bool active; + final bool sidebarVisible; + final double sidebarWidth; + final ScheduleSearchCriteria? criteria; + final LinuxScheduleSearchFilterLabels labels; + final List accounts; + final List calendarSources; + final List taskLists; + + Map toJson() => { + 'schemaVersion': schemaVersion, + 'active': active, + 'sidebarVisible': sidebarVisible, + 'sidebarWidth': sidebarWidth, + 'criteria': criteria == null ? null : _criteriaToJson(criteria!), + 'labels': labels.toJson(), + 'accounts': [for (final account in accounts) account.toJson()], + 'calendarSources': [for (final source in calendarSources) source.toJson()], + 'taskLists': [for (final taskList in taskLists) taskList.toJson()], + }; + + static Map _criteriaToJson( + ScheduleSearchCriteria criteria, + ) => { + 'type': criteria.type.name, + 'date': criteria.date.name, + 'taskCompletion': criteria.taskCompletion.name, + 'taskDueState': criteria.taskDueState.name, + 'person': criteria.person, + 'location': criteria.location, + 'customStart': _encodeDate(criteria.customStart), + 'customEnd': _encodeDate(criteria.customEnd), + 'referenceDate': _encodeDate(criteria.referenceDate), + 'firstWeekday': criteria.firstWeekday, + 'sourceIds': criteria.sourceIds.toList()..sort(), + 'taskListKeys': + [ + for (final key in criteria.taskListKeys) + { + 'accountId': key.accountId, + 'taskListId': key.taskListId, + }, + ]..sort((a, b) { + final account = a['accountId']!.compareTo(b['accountId']!); + return account != 0 + ? account + : a['taskListId']!.compareTo(b['taskListId']!); + }), + }; +} + +String? _encodeDate(DateTime? value) { + if (value == null) return null; + String two(int part) => part.toString().padLeft(2, '0'); + return '${value.year.toString().padLeft(4, '0')}-${two(value.month)}-${two(value.day)}'; +} + +sealed class LinuxScheduleSearchFilterEvent { + const LinuxScheduleSearchFilterEvent(); +} + +final class LinuxScheduleSearchTypeChanged + extends LinuxScheduleSearchFilterEvent { + const LinuxScheduleSearchTypeChanged(this.value); + final ScheduleSearchType value; +} + +final class LinuxScheduleSearchDateChanged + extends LinuxScheduleSearchFilterEvent { + const LinuxScheduleSearchDateChanged(this.value); + final ScheduleSearchDate value; +} + +final class LinuxScheduleSearchTaskCompletionChanged + extends LinuxScheduleSearchFilterEvent { + const LinuxScheduleSearchTaskCompletionChanged(this.value); + final ScheduleTaskCompletion value; +} + +final class LinuxScheduleSearchTaskDueStateChanged + extends LinuxScheduleSearchFilterEvent { + const LinuxScheduleSearchTaskDueStateChanged(this.value); + final ScheduleTaskDueState value; +} + +final class LinuxScheduleSearchPersonChanged + extends LinuxScheduleSearchFilterEvent { + const LinuxScheduleSearchPersonChanged(this.value); + final String value; +} + +final class LinuxScheduleSearchLocationChanged + extends LinuxScheduleSearchFilterEvent { + const LinuxScheduleSearchLocationChanged(this.value); + final String value; +} + +final class LinuxScheduleSearchCustomStartChanged + extends LinuxScheduleSearchFilterEvent { + const LinuxScheduleSearchCustomStartChanged(this.value); + final DateTime value; +} + +final class LinuxScheduleSearchCustomEndChanged + extends LinuxScheduleSearchFilterEvent { + const LinuxScheduleSearchCustomEndChanged(this.value); + final DateTime value; +} + +final class LinuxScheduleSearchCalendarSourceToggled + extends LinuxScheduleSearchFilterEvent { + const LinuxScheduleSearchCalendarSourceToggled({ + required this.sourceId, + required this.selected, + }); + final String sourceId; + final bool selected; +} + +final class LinuxScheduleSearchTaskListToggled + extends LinuxScheduleSearchFilterEvent { + const LinuxScheduleSearchTaskListToggled({ + required this.key, + required this.selected, + }); + final ScheduleTaskListKey key; + final bool selected; +} + +final class LinuxScheduleSearchClearRequested + extends LinuxScheduleSearchFilterEvent { + const LinuxScheduleSearchClearRequested(); +} + +final class LinuxScheduleSearchDismissRequested + extends LinuxScheduleSearchFilterEvent { + const LinuxScheduleSearchDismissRequested(); +} + +class LinuxScheduleSearchFilterService { + LinuxScheduleSearchFilterService({ + MethodChannel channel = const MethodChannel( + linuxScheduleSearchFilterChannelName, + ), + bool? isLinux, + }) : _channel = channel, + _isLinux = isLinux ?? Platform.isLinux; + + final MethodChannel _channel; + final bool _isLinux; + final List _sessions = []; + Future? _initialization; + bool _available = false; + bool _disposed = false; + Map? _appliedState; + + bool get isAvailable => _available; + + LinuxScheduleSearchFilterSession claimSession() { + if (_disposed) { + throw StateError('Cannot claim a session from a disposed service.'); + } + final session = LinuxScheduleSearchFilterSession._(this); + _sessions.add(session); + return session; + } + + Future initialize() => _initialization ??= _initialize(); + + Future _initialize() async { + if (_disposed) return; + _channel.setMethodCallHandler(handleNativeMethodCall); + if (!_isLinux) return; + try { + _available = await _channel.invokeMethod('initialize') ?? false; + } on MissingPluginException { + _available = false; + } on PlatformException { + _available = false; + } + } + + LinuxScheduleSearchFilterSession? get _activeSession => + _sessions.isEmpty ? null : _sessions.last; + + bool _isCurrent(LinuxScheduleSearchFilterSession session) => + identical(session, _activeSession); + + Future _applyState( + LinuxScheduleSearchFilterState state, { + bool force = false, + }) async { + final serialized = state.toJson(); + if (!force && + const DeepCollectionEquality().equals(_appliedState, serialized)) { + return; + } + _appliedState = serialized; + await _invokeVoid('setState', serialized); + } + + Future _showModal(LinuxScheduleSearchFilterSession session) async { + if (!_available || !_isCurrent(session)) return false; + try { + return await _channel.invokeMethod('showModal') ?? false; + } on MissingPluginException { + _available = false; + } on PlatformException { + _available = false; + } + return false; + } + + Future _hideModal(LinuxScheduleSearchFilterSession session) async { + if (!_available || !_isCurrent(session)) return; + await _invokeVoid('hideModal'); + } + + Future _invokeVoid(String method, [Object? arguments]) async { + if (_disposed || !_available) return; + try { + await _channel.invokeMethod(method, arguments); + } on MissingPluginException { + _available = false; + } on PlatformException { + _available = false; + } + } + + @visibleForTesting + Future handleNativeMethodCall(MethodCall call) async { + if (_disposed || !_available) return; + final event = _eventFor(call); + if (event != null) _activeSession?._dispatch(event); + } + + LinuxScheduleSearchFilterEvent? _eventFor(MethodCall call) { + try { + return switch ((call.method, call.arguments)) { + ('typeChanged', final String value) => LinuxScheduleSearchTypeChanged( + ScheduleSearchType.values.byName(value), + ), + ('dateChanged', final String value) => LinuxScheduleSearchDateChanged( + ScheduleSearchDate.values.byName(value), + ), + ('taskCompletionChanged', final String value) => + LinuxScheduleSearchTaskCompletionChanged( + ScheduleTaskCompletion.values.byName(value), + ), + ('taskDueStateChanged', final String value) => + LinuxScheduleSearchTaskDueStateChanged( + ScheduleTaskDueState.values.byName(value), + ), + ('personChanged', final String value) => + LinuxScheduleSearchPersonChanged(value), + ('locationChanged', final String value) => + LinuxScheduleSearchLocationChanged(value), + ('customStartChanged', final String value) => + LinuxScheduleSearchCustomStartChanged(DateTime.parse(value)), + ('customEndChanged', final String value) => + LinuxScheduleSearchCustomEndChanged(DateTime.parse(value)), + ('calendarSourceToggled', final Map value) + when value['sourceId'] is String && value['selected'] is bool => + LinuxScheduleSearchCalendarSourceToggled( + sourceId: value['sourceId']! as String, + selected: value['selected']! as bool, + ), + ('taskListToggled', final Map value) + when value['accountId'] is String && + value['taskListId'] is String && + value['selected'] is bool => + LinuxScheduleSearchTaskListToggled( + key: ScheduleTaskListKey( + accountId: value['accountId']! as String, + taskListId: value['taskListId']! as String, + ), + selected: value['selected']! as bool, + ), + ('clearFilters', _) => const LinuxScheduleSearchClearRequested(), + ('dismissSearch', _) => const LinuxScheduleSearchDismissRequested(), + _ => null, + }; + } on ArgumentError { + return null; + } on FormatException { + return null; + } + } + + void _release(LinuxScheduleSearchFilterSession session) { + final wasCurrent = _isCurrent(session); + _sessions.remove(session); + if (!wasCurrent) return; + final current = _activeSession; + if (current != null) { + unawaited(current._restore()); + } else { + _appliedState = null; + unawaited(_invokeVoid('hide')); + } + } + + void dispose() { + if (_disposed) return; + if (_available) unawaited(_invokeVoid('hide')); + _disposed = true; + _available = false; + for (final session in _sessions.toList()) { + session._disposeFromService(); + } + _sessions.clear(); + _channel.setMethodCallHandler(null); + } +} + +class LinuxScheduleSearchFilterSession { + LinuxScheduleSearchFilterSession._(this._service); + + final LinuxScheduleSearchFilterService _service; + final StreamController _events = + StreamController.broadcast(); + LinuxScheduleSearchFilterState? _state; + int _revision = 0; + bool _disposed = false; + + bool get isCurrent => !_disposed && _service._isCurrent(this); + bool get isAvailable => !_disposed && _service.isAvailable; + Stream get events => _events.stream; + + Future initialize() => _service.initialize(); + + Future updateState( + LinuxScheduleSearchFilterState state, { + bool force = false, + }) async { + if (_disposed) return; + _state = state; + final revision = ++_revision; + await initialize(); + if (!isCurrent || revision != _revision) return; + await _service._applyState(state, force: force); + } + + Future showModal() async { + if (_disposed) return false; + await initialize(); + if (!isCurrent) return false; + final state = _state; + if (state != null) await _service._applyState(state, force: true); + return _service._showModal(this); + } + + Future hideModal() => _service._hideModal(this); + + Future _restore() async { + await initialize(); + final state = _state; + if (!isCurrent || state == null) return; + await _service._applyState(state, force: true); + } + + void _dispatch(LinuxScheduleSearchFilterEvent event) { + if (isCurrent && !_events.isClosed) _events.add(event); + } + + void dispose() { + if (_disposed) return; + _disposed = true; + _service._release(this); + unawaited(_events.close()); + } + + void _disposeFromService() { + if (_disposed) return; + _disposed = true; + unawaited(_events.close()); + } +} + +final linuxScheduleSearchFilterServiceProvider = + Provider((ref) { + final service = LinuxScheduleSearchFilterService(); + ref.onDispose(service.dispose); + return service; + }); diff --git a/linux/runner/my_application.cc b/linux/runner/my_application.cc index a226ea8a..cd3578e5 100644 --- a/linux/runner/my_application.cc +++ b/linux/runner/my_application.cc @@ -10,6 +10,7 @@ #include #include #include +#include #include "flutter/generated_plugin_registrant.h" constexpr char kApplicationDisplayName[] = "BusyMax"; @@ -17,6 +18,8 @@ constexpr char kNativeDateTimePickerChannel[] = "busymax/native_date_time_picker"; constexpr char kNativeDialogChannel[] = "busymax/native_dialogs"; constexpr char kNativeMenuChannel[] = "busymax/native_menus"; +constexpr char kNativeScheduleSearchFiltersChannel[] = + "busymax/native_schedule_search_filters"; constexpr char kWindowChannel[] = "io.busystack.busymax/window"; constexpr char kHeaderBarChannel[] = "io.busystack.busymax/headerbar"; constexpr char kGtkSettingsChannel[] = "io.busystack.busymax/gtk_settings"; @@ -93,6 +96,11 @@ constexpr char kNativeTimeZoneRowStyleClass[] = "busymax-time-zone-row"; constexpr gint kNativeTimeZoneDialogWidth = 520; constexpr gint kNativeTimeZoneDialogContentHeight = 420; constexpr size_t kNativeTimeZoneResultLimit = 250; +constexpr gint64 kNativeScheduleSearchFilterSchemaVersion = 1; +constexpr gint kNativeScheduleSearchFilterDialogWidth = 420; +constexpr gint kNativeScheduleSearchFilterDialogHeight = 680; + +struct NativeScheduleFilterControls; struct _MyApplication { GtkApplication parent_instance; @@ -101,6 +109,7 @@ struct _MyApplication { FlMethodChannel* native_date_time_picker_channel; FlMethodChannel* native_dialog_channel; FlMethodChannel* native_menu_channel; + FlMethodChannel* native_schedule_search_filters_channel; FlMethodChannel* window_channel; FlMethodChannel* header_bar_channel; FlMethodChannel* gtk_settings_channel; @@ -142,6 +151,7 @@ struct _MyApplication { GtkWindow* main_window; GtkWindow* header_focus_transient_window; GtkWidget* flutter_view; + GtkWidget* content_overlay; GtkWidget* titlebar_handle; GtkWidget* titlebar_overlay; GtkWidget* titlebar_modal_barrier; @@ -211,6 +221,10 @@ struct _MyApplication { gboolean header_back_visible; gboolean header_onboarding_controls_visible; gint header_onboarding_content_width; + NativeScheduleFilterControls* schedule_filter_sidebar; + NativeScheduleFilterControls* schedule_filter_modal; + FlValue* schedule_filter_state; + gboolean suppress_schedule_filter_events; }; struct PendingExternalOpen { @@ -468,13 +482,11 @@ static void style_native_dialog(GtkWidget* dialog) { } } -static void handle_pick_date(FlMethodCall* method_call, - FlValue* args, - GtkWindow* parent) { - const gchar* title = fl_lookup_string_arg(args, "title"); - const gchar* initial_date = fl_lookup_string_arg(args, "initialDate"); - const gchar* cancel_label = fl_lookup_string_arg(args, "cancelLabel"); - const gchar* ok_label = fl_lookup_string_arg(args, "okLabel"); +static gchar* run_native_date_picker(GtkWindow* parent, + const gchar* title, + const gchar* initial_date, + const gchar* cancel_label, + const gchar* ok_label) { GtkWidget* dialog = gtk_dialog_new_with_buttons( title != nullptr ? title : "Date", parent, static_cast(GTK_DIALOG_MODAL | @@ -505,16 +517,25 @@ static void handle_pick_date(FlMethodCall* method_call, gtk_widget_show_all(dialog); const gint response = gtk_dialog_run(GTK_DIALOG(dialog)); + gchar* result = nullptr; if (response == GTK_RESPONSE_OK) { gtk_calendar_get_date(GTK_CALENDAR(calendar), &year, &month, &day); - g_autofree gchar* result = - g_strdup_printf("%04u-%02u-%02u", year, month + 1, day); - respond_string(method_call, result); - } else { - respond_string(method_call, nullptr); + result = g_strdup_printf("%04u-%02u-%02u", year, month + 1, day); } gtk_widget_destroy(dialog); + return result; +} + +static void handle_pick_date(FlMethodCall* method_call, + FlValue* args, + GtkWindow* parent) { + g_autofree gchar* result = run_native_date_picker( + parent, fl_lookup_string_arg(args, "title"), + fl_lookup_string_arg(args, "initialDate"), + fl_lookup_string_arg(args, "cancelLabel"), + fl_lookup_string_arg(args, "okLabel")); + respond_string(method_call, result); } static void handle_pick_time(FlMethodCall* method_call, @@ -1956,6 +1977,1118 @@ static void respond_success(FlMethodCall* method_call) { fl_method_call_respond_success(method_call, result, nullptr); } +struct NativeScheduleFilterSourceRow { + GtkWidget* group; + GtkWidget* row; + GtkWidget* check_button; + gboolean is_task_list; + gchar* source_id; + gchar* account_id; + gchar* task_list_id; +}; + +struct NativeScheduleFilterControls { + MyApplication* application; + GtkWidget* window; + GtkWidget* root; + GtkWidget* scrolled_window; + GtkWidget* content; + GtkWidget* title_label; + GtkWidget* general_group; + GtkWidget* type_row; + GtkWidget* type_combo; + GtkWidget* date_row; + GtkWidget* date_combo; + GtkWidget* start_date_row; + GtkWidget* start_date_button; + GtkWidget* end_date_row; + GtkWidget* end_date_button; + GtkWidget* task_completion_row; + GtkWidget* task_completion_combo; + GtkWidget* task_due_row; + GtkWidget* task_due_combo; + GtkWidget* person_row; + GtkWidget* person_entry; + GtkWidget* location_row; + GtkWidget* location_entry; + GtkWidget* sources_label; + GtkWidget* sources_box; + GtkWidget* no_sources_label; + GtkWidget* clear_button; + GPtrArray* source_rows; + gchar* topology_signature; + gchar* labels_signature; + gboolean modal; +}; + +static void native_schedule_filter_source_row_free(gpointer data) { + auto* row = static_cast(data); + if (row == nullptr) return; + g_free(row->source_id); + g_free(row->account_id); + g_free(row->task_list_id); + g_free(row); +} + +static void native_schedule_filter_controls_free( + NativeScheduleFilterControls* controls) { + if (controls == nullptr) return; + g_clear_pointer(&controls->source_rows, g_ptr_array_unref); + g_clear_pointer(&controls->topology_signature, g_free); + g_clear_pointer(&controls->labels_signature, g_free); + g_free(controls); +} + +static FlValue* native_schedule_filter_criteria(MyApplication* self) { + return self->schedule_filter_state == nullptr + ? nullptr + : fl_lookup_map_arg(self->schedule_filter_state, "criteria"); +} + +static FlValue* fl_lookup_list_arg(FlValue* args, const gchar* key) { + if (args == nullptr || fl_value_get_type(args) != FL_VALUE_TYPE_MAP) { + return nullptr; + } + FlValue* value = fl_value_lookup_string(args, key); + return value != nullptr && fl_value_get_type(value) == FL_VALUE_TYPE_LIST + ? value + : nullptr; +} + +static gboolean native_schedule_filter_has_string(FlValue* map, + const gchar* key) { + return fl_lookup_string_arg(map, key) != nullptr; +} + +static gboolean native_schedule_filter_valid_enum(const gchar* value, + const gchar* const* values, + gsize count) { + if (value == nullptr) return FALSE; + for (gsize index = 0; index < count; index++) { + if (g_strcmp0(value, values[index]) == 0) return TRUE; + } + return FALSE; +} + +static gboolean native_schedule_filter_valid_optional_date(FlValue* map, + const gchar* key) { + FlValue* value = fl_value_lookup_string(map, key); + if (value == nullptr || fl_value_get_type(value) == FL_VALUE_TYPE_NULL) { + return TRUE; + } + if (fl_value_get_type(value) != FL_VALUE_TYPE_STRING) return FALSE; + guint year = 0; + guint month = 0; + guint day = 0; + return parse_date(fl_value_get_string(value), &year, &month, &day); +} + +static gboolean native_schedule_filter_validate_item_list( + FlValue* list, + const gchar* const* string_keys, + gsize string_key_count, + gboolean requires_selected) { + if (list == nullptr) return FALSE; + for (size_t index = 0; index < fl_value_get_length(list); index++) { + FlValue* item = fl_value_get_list_value(list, index); + if (item == nullptr || fl_value_get_type(item) != FL_VALUE_TYPE_MAP) { + return FALSE; + } + for (gsize key_index = 0; key_index < string_key_count; key_index++) { + if (!native_schedule_filter_has_string(item, string_keys[key_index])) { + return FALSE; + } + } + if (requires_selected) { + gboolean selected = FALSE; + if (!fl_lookup_optional_bool_arg(item, "selected", &selected)) { + return FALSE; + } + (void)selected; + } + } + return TRUE; +} + +static gboolean validate_native_schedule_filter_state(FlValue* args, + const gchar** error) { + if (args == nullptr || fl_value_get_type(args) != FL_VALUE_TYPE_MAP) { + *error = "The filter state must be a map."; + return FALSE; + } + gint64 schema_version = 0; + if (!fl_lookup_int_arg(args, "schemaVersion", &schema_version) || + schema_version != kNativeScheduleSearchFilterSchemaVersion) { + *error = "The filter state schema version is unsupported."; + return FALSE; + } + gboolean active = FALSE; + gboolean sidebar_visible = FALSE; + if (!fl_lookup_optional_bool_arg(args, "active", &active) || + !fl_lookup_optional_bool_arg(args, "sidebarVisible", + &sidebar_visible)) { + *error = "The filter visibility state is invalid."; + return FALSE; + } + (void)sidebar_visible; + gdouble sidebar_width = 0; + if (!fl_lookup_double_arg(args, "sidebarWidth", &sidebar_width) || + sidebar_width < 160 || sidebar_width > 800) { + *error = "The filter sidebar width is invalid."; + return FALSE; + } + FlValue* labels = fl_lookup_map_arg(args, "labels"); + constexpr const gchar* label_keys[] = { + "searchFilters", "type", "all", "events", + "tasks", "date", "anyDate", "today", + "tomorrow", "thisWeek", "customRange", "startDate", + "endDate", "taskStatus", "open", "completed", + "taskDue", "anyDueState", "overdue", "noDueDate", + "person", "location", "sources", "clearFilters", + "noSources", "close", "cancel", "ok", + }; + if (labels == nullptr) { + *error = "The localized filter labels are missing."; + return FALSE; + } + for (const gchar* key : label_keys) { + if (!native_schedule_filter_has_string(labels, key)) { + *error = "A localized filter label is invalid."; + return FALSE; + } + } + constexpr const gchar* account_keys[] = {"id", "label"}; + constexpr const gchar* source_keys[] = {"id", "accountId", "title"}; + constexpr const gchar* task_list_keys[] = {"accountId", "taskListId", + "title"}; + if (!native_schedule_filter_validate_item_list( + fl_lookup_list_arg(args, "accounts"), account_keys, + G_N_ELEMENTS(account_keys), FALSE) || + !native_schedule_filter_validate_item_list( + fl_lookup_list_arg(args, "calendarSources"), source_keys, + G_N_ELEMENTS(source_keys), TRUE) || + !native_schedule_filter_validate_item_list( + fl_lookup_list_arg(args, "taskLists"), task_list_keys, + G_N_ELEMENTS(task_list_keys), TRUE)) { + *error = "The filter source presentation is invalid."; + return FALSE; + } + FlValue* criteria = fl_lookup_map_arg(args, "criteria"); + if (!active && criteria == nullptr) return TRUE; + if (criteria == nullptr) { + *error = "Active filters require criteria."; + return FALSE; + } + constexpr const gchar* types[] = {"all", "events", "tasks"}; + constexpr const gchar* dates[] = {"any", "today", "tomorrow", "thisWeek", + "custom"}; + constexpr const gchar* completion[] = {"open", "all", "completed"}; + constexpr const gchar* due_states[] = {"any", "overdue", "noDueDate"}; + if (!native_schedule_filter_valid_enum( + fl_lookup_string_arg(criteria, "type"), types, + G_N_ELEMENTS(types)) || + !native_schedule_filter_valid_enum( + fl_lookup_string_arg(criteria, "date"), dates, + G_N_ELEMENTS(dates)) || + !native_schedule_filter_valid_enum( + fl_lookup_string_arg(criteria, "taskCompletion"), completion, + G_N_ELEMENTS(completion)) || + !native_schedule_filter_valid_enum( + fl_lookup_string_arg(criteria, "taskDueState"), due_states, + G_N_ELEMENTS(due_states)) || + !native_schedule_filter_has_string(criteria, "person") || + !native_schedule_filter_has_string(criteria, "location") || + !native_schedule_filter_valid_optional_date(criteria, "customStart") || + !native_schedule_filter_valid_optional_date(criteria, "customEnd") || + !native_schedule_filter_valid_optional_date(criteria, "referenceDate") || + fl_lookup_list_arg(criteria, "sourceIds") == nullptr || + fl_lookup_list_arg(criteria, "taskListKeys") == nullptr) { + *error = "The filter criteria are invalid."; + return FALSE; + } + return TRUE; +} + +static void invoke_native_schedule_filter_event(MyApplication* self, + const gchar* method, + FlValue* arguments) { + if (self->suppress_schedule_filter_events || + self->native_schedule_search_filters_channel == nullptr || + method == nullptr) { + return; + } + fl_method_channel_invoke_method( + self->native_schedule_search_filters_channel, method, arguments, + nullptr, nullptr, nullptr); +} + +static void invoke_native_schedule_filter_string_event( + MyApplication* self, + const gchar* method, + const gchar* value) { + g_autoptr(FlValue) argument = + fl_value_new_string(value == nullptr ? "" : value); + invoke_native_schedule_filter_event(self, method, argument); +} + +static const gchar* native_schedule_filter_combo_value(GtkComboBox* combo, + const gchar* kind) { + const gint index = gtk_combo_box_get_active(combo); + if (g_strcmp0(kind, "type") == 0) { + constexpr const gchar* values[] = {"all", "events", "tasks"}; + return index >= 0 && index < static_cast(G_N_ELEMENTS(values)) + ? values[index] + : nullptr; + } + if (g_strcmp0(kind, "date") == 0) { + constexpr const gchar* values[] = {"any", "today", "tomorrow", + "thisWeek", "custom"}; + return index >= 0 && index < static_cast(G_N_ELEMENTS(values)) + ? values[index] + : nullptr; + } + if (g_strcmp0(kind, "completion") == 0) { + constexpr const gchar* values[] = {"open", "all", "completed"}; + return index >= 0 && index < static_cast(G_N_ELEMENTS(values)) + ? values[index] + : nullptr; + } + constexpr const gchar* values[] = {"any", "overdue", "noDueDate"}; + return index >= 0 && index < static_cast(G_N_ELEMENTS(values)) + ? values[index] + : nullptr; +} + +static void native_schedule_filter_combo_changed_cb(GtkComboBox* combo, + gpointer user_data) { + auto* controls = static_cast(user_data); + const gchar* kind = static_cast( + g_object_get_data(G_OBJECT(combo), "busymax-filter-kind")); + const gchar* value = native_schedule_filter_combo_value(combo, kind); + if (value == nullptr) return; + const gchar* method = g_strcmp0(kind, "type") == 0 + ? "typeChanged" + : g_strcmp0(kind, "date") == 0 + ? "dateChanged" + : g_strcmp0(kind, "completion") == 0 + ? "taskCompletionChanged" + : "taskDueStateChanged"; + invoke_native_schedule_filter_string_event(controls->application, method, + value); +} + +static void native_schedule_filter_entry_changed_cb(GtkEditable* entry, + gpointer user_data) { + auto* controls = static_cast(user_data); + const gchar* kind = static_cast( + g_object_get_data(G_OBJECT(entry), "busymax-filter-kind")); + invoke_native_schedule_filter_string_event( + controls->application, + g_strcmp0(kind, "person") == 0 ? "personChanged" : "locationChanged", + gtk_entry_get_text(GTK_ENTRY(entry))); +} + +static void native_schedule_filter_source_toggled_cb(GtkToggleButton* button, + gpointer user_data) { + auto* source_row = static_cast(user_data); + auto* controls = static_cast( + g_object_get_data(G_OBJECT(button), "busymax-filter-controls")); + if (controls == nullptr || source_row == nullptr) return; + g_autoptr(FlValue) args = fl_value_new_map(); + if (source_row->is_task_list) { + fl_value_set_string_take( + args, "accountId", + fl_value_new_string(source_row->account_id == nullptr + ? "" + : source_row->account_id)); + fl_value_set_string_take( + args, "taskListId", + fl_value_new_string(source_row->task_list_id == nullptr + ? "" + : source_row->task_list_id)); + } else { + fl_value_set_string_take( + args, "sourceId", + fl_value_new_string(source_row->source_id == nullptr + ? "" + : source_row->source_id)); + } + fl_value_set_string_take( + args, "selected", + fl_value_new_bool(gtk_toggle_button_get_active(button))); + invoke_native_schedule_filter_event( + controls->application, + source_row->is_task_list ? "taskListToggled" : "calendarSourceToggled", + args); +} + +static void native_schedule_filter_clear_clicked_cb(GtkButton*, + gpointer user_data) { + auto* controls = static_cast(user_data); + invoke_native_schedule_filter_event(controls->application, "clearFilters", + nullptr); +} + +static gchar* native_schedule_filter_format_date(const gchar* value) { + guint year = 0; + guint month = 0; + guint day = 0; + if (!parse_date(value, &year, &month, &day)) { + return g_strdup(value == nullptr ? "" : value); + } + g_autoptr(GDateTime) date = + g_date_time_new_local(year, month, day, 12, 0, 0); + return date == nullptr ? g_strdup(value) : g_date_time_format(date, "%x"); +} + +static const gchar* native_schedule_filter_date_value(FlValue* criteria, + const gchar* key) { + FlValue* value = criteria == nullptr + ? nullptr + : fl_value_lookup_string(criteria, key); + return value != nullptr && fl_value_get_type(value) == FL_VALUE_TYPE_STRING + ? fl_value_get_string(value) + : nullptr; +} + +static void native_schedule_filter_date_clicked_cb(GtkButton* button, + gpointer user_data) { + auto* controls = static_cast(user_data); + MyApplication* self = controls->application; + FlValue* criteria = native_schedule_filter_criteria(self); + FlValue* labels = self->schedule_filter_state == nullptr + ? nullptr + : fl_lookup_map_arg(self->schedule_filter_state, + "labels"); + if (criteria == nullptr || labels == nullptr) return; + const gboolean is_start = + g_strcmp0(static_cast(g_object_get_data( + G_OBJECT(button), "busymax-filter-date-kind")), + "start") == 0; + const gchar* value = native_schedule_filter_date_value( + criteria, is_start ? "customStart" : "customEnd"); + if (value == nullptr) { + value = native_schedule_filter_date_value(criteria, "referenceDate"); + } + GtkWidget* toplevel = gtk_widget_get_toplevel(GTK_WIDGET(button)); + GtkWindow* parent = GTK_IS_WINDOW(toplevel) ? GTK_WINDOW(toplevel) + : self->main_window; + g_autofree gchar* result = run_native_date_picker( + parent, + fl_lookup_string_arg(labels, is_start ? "startDate" : "endDate"), + value, fl_lookup_string_arg(labels, "cancel"), + fl_lookup_string_arg(labels, "ok")); + if (result != nullptr) { + invoke_native_schedule_filter_string_event( + self, is_start ? "customStartChanged" : "customEndChanged", result); + } +} + +static GtkWidget* native_schedule_filter_action_row(const gchar* kind, + GtkWidget* control, + GCallback callback, + gpointer user_data) { + GtkWidget* row = hdy_action_row_new(); + gtk_widget_set_hexpand(row, TRUE); + gtk_widget_set_halign(row, GTK_ALIGN_FILL); + gtk_widget_set_valign(control, GTK_ALIGN_CENTER); + g_object_set_data_full(G_OBJECT(control), "busymax-filter-kind", + g_strdup(kind), g_free); + gtk_container_add(GTK_CONTAINER(row), control); + hdy_action_row_set_activatable_widget(HDY_ACTION_ROW(row), control); + gtk_list_box_row_set_activatable(GTK_LIST_BOX_ROW(row), TRUE); + g_signal_connect(control, + GTK_IS_COMBO_BOX(control) ? "changed" : "search-changed", + callback, user_data); + return row; +} + +static void native_schedule_filter_controls_destroyed_cb(GtkWidget*, + gpointer user_data) { + auto* controls = static_cast(user_data); + MyApplication* self = controls->application; + if (self->schedule_filter_sidebar == controls) { + self->schedule_filter_sidebar = nullptr; + } + if (self->schedule_filter_modal == controls) { + self->schedule_filter_modal = nullptr; + if (self->main_window != nullptr) { + gtk_window_present(self->main_window); + } + } + native_schedule_filter_controls_free(controls); +} + +static NativeScheduleFilterControls* create_native_schedule_filter_controls( + MyApplication* self, + gboolean modal) { + auto* controls = g_new0(NativeScheduleFilterControls, 1); + controls->application = self; + controls->modal = modal; + controls->source_rows = g_ptr_array_new_with_free_func( + native_schedule_filter_source_row_free); + + controls->root = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0); + gtk_style_context_add_class(gtk_widget_get_style_context(controls->root), + GTK_STYLE_CLASS_BACKGROUND); + gtk_widget_set_hexpand(controls->root, FALSE); + gtk_widget_set_vexpand(controls->root, TRUE); + g_signal_connect(controls->root, "destroy", + G_CALLBACK(native_schedule_filter_controls_destroyed_cb), + controls); + + controls->scrolled_window = gtk_scrolled_window_new(nullptr, nullptr); + gtk_scrolled_window_set_policy( + GTK_SCROLLED_WINDOW(controls->scrolled_window), GTK_POLICY_NEVER, + GTK_POLICY_AUTOMATIC); + gtk_scrolled_window_set_shadow_type( + GTK_SCROLLED_WINDOW(controls->scrolled_window), GTK_SHADOW_NONE); + gtk_container_add(GTK_CONTAINER(controls->root), controls->scrolled_window); + + controls->content = gtk_box_new(GTK_ORIENTATION_VERTICAL, 12); + gtk_container_set_border_width(GTK_CONTAINER(controls->content), 12); + gtk_container_add(GTK_CONTAINER(controls->scrolled_window), + controls->content); + + controls->title_label = gtk_label_new(nullptr); + gtk_widget_set_halign(controls->title_label, GTK_ALIGN_START); + gtk_style_context_add_class( + gtk_widget_get_style_context(controls->title_label), "title"); + gtk_box_pack_start(GTK_BOX(controls->content), controls->title_label, FALSE, + FALSE, 0); + + controls->general_group = hdy_preferences_group_new(); + gtk_widget_set_hexpand(controls->general_group, TRUE); + gtk_box_pack_start(GTK_BOX(controls->content), controls->general_group, + FALSE, FALSE, 0); + + controls->type_combo = gtk_combo_box_text_new(); + controls->type_row = native_schedule_filter_action_row( + "type", controls->type_combo, + G_CALLBACK(native_schedule_filter_combo_changed_cb), controls); + gtk_container_add(GTK_CONTAINER(controls->general_group), + controls->type_row); + controls->date_combo = gtk_combo_box_text_new(); + controls->date_row = native_schedule_filter_action_row( + "date", controls->date_combo, + G_CALLBACK(native_schedule_filter_combo_changed_cb), controls); + gtk_container_add(GTK_CONTAINER(controls->general_group), + controls->date_row); + + controls->start_date_button = gtk_button_new(); + g_object_set_data_full(G_OBJECT(controls->start_date_button), + "busymax-filter-date-kind", g_strdup("start"), + g_free); + controls->start_date_row = hdy_action_row_new(); + gtk_container_add(GTK_CONTAINER(controls->start_date_row), + controls->start_date_button); + hdy_action_row_set_activatable_widget( + HDY_ACTION_ROW(controls->start_date_row), controls->start_date_button); + g_signal_connect(controls->start_date_button, "clicked", + G_CALLBACK(native_schedule_filter_date_clicked_cb), + controls); + gtk_container_add(GTK_CONTAINER(controls->general_group), + controls->start_date_row); + + controls->end_date_button = gtk_button_new(); + g_object_set_data_full(G_OBJECT(controls->end_date_button), + "busymax-filter-date-kind", g_strdup("end"), + g_free); + controls->end_date_row = hdy_action_row_new(); + gtk_container_add(GTK_CONTAINER(controls->end_date_row), + controls->end_date_button); + hdy_action_row_set_activatable_widget( + HDY_ACTION_ROW(controls->end_date_row), controls->end_date_button); + g_signal_connect(controls->end_date_button, "clicked", + G_CALLBACK(native_schedule_filter_date_clicked_cb), + controls); + gtk_container_add(GTK_CONTAINER(controls->general_group), + controls->end_date_row); + + controls->task_completion_combo = gtk_combo_box_text_new(); + controls->task_completion_row = native_schedule_filter_action_row( + "completion", controls->task_completion_combo, + G_CALLBACK(native_schedule_filter_combo_changed_cb), controls); + gtk_container_add(GTK_CONTAINER(controls->general_group), + controls->task_completion_row); + controls->task_due_combo = gtk_combo_box_text_new(); + controls->task_due_row = native_schedule_filter_action_row( + "due", controls->task_due_combo, + G_CALLBACK(native_schedule_filter_combo_changed_cb), controls); + gtk_container_add(GTK_CONTAINER(controls->general_group), + controls->task_due_row); + + controls->person_entry = gtk_search_entry_new(); + controls->person_row = native_schedule_filter_action_row( + "person", controls->person_entry, + G_CALLBACK(native_schedule_filter_entry_changed_cb), controls); + gtk_container_add(GTK_CONTAINER(controls->general_group), + controls->person_row); + controls->location_entry = gtk_search_entry_new(); + controls->location_row = native_schedule_filter_action_row( + "location", controls->location_entry, + G_CALLBACK(native_schedule_filter_entry_changed_cb), controls); + gtk_container_add(GTK_CONTAINER(controls->general_group), + controls->location_row); + + controls->sources_label = gtk_label_new(nullptr); + gtk_widget_set_halign(controls->sources_label, GTK_ALIGN_START); + gtk_style_context_add_class( + gtk_widget_get_style_context(controls->sources_label), "heading"); + gtk_box_pack_start(GTK_BOX(controls->content), controls->sources_label, + FALSE, FALSE, 0); + controls->sources_box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 12); + gtk_widget_set_hexpand(controls->sources_box, TRUE); + gtk_box_pack_start(GTK_BOX(controls->content), controls->sources_box, FALSE, + FALSE, 0); + controls->no_sources_label = gtk_label_new(nullptr); + gtk_label_set_line_wrap(GTK_LABEL(controls->no_sources_label), TRUE); + gtk_widget_set_halign(controls->no_sources_label, GTK_ALIGN_START); + gtk_box_pack_start(GTK_BOX(controls->content), controls->no_sources_label, + FALSE, FALSE, 0); + controls->clear_button = gtk_button_new(); + gtk_widget_set_halign(controls->clear_button, GTK_ALIGN_START); + g_signal_connect(controls->clear_button, "clicked", + G_CALLBACK(native_schedule_filter_clear_clicked_cb), + controls); + gtk_box_pack_start(GTK_BOX(controls->content), controls->clear_button, FALSE, + FALSE, 0); + + return controls; +} + +static gchar* native_schedule_filter_topology_signature(FlValue* state) { + GString* signature = g_string_new(nullptr); + constexpr const gchar* list_names[] = {"accounts", "calendarSources", + "taskLists"}; + constexpr const gchar* keys[][3] = {{"id", "label", nullptr}, + {"id", "accountId", "title"}, + {"accountId", "taskListId", "title"}}; + for (gsize list_index = 0; list_index < G_N_ELEMENTS(list_names); + list_index++) { + FlValue* list = fl_lookup_list_arg(state, list_names[list_index]); + g_string_append_printf(signature, "%s:%zu;", list_names[list_index], + fl_value_get_length(list)); + for (size_t index = 0; index < fl_value_get_length(list); index++) { + FlValue* item = fl_value_get_list_value(list, index); + for (const gchar* key : keys[list_index]) { + if (key == nullptr) continue; + const gchar* value = fl_lookup_string_arg(item, key); + g_string_append_printf(signature, "%zu:%s;", strlen(value), value); + } + } + } + return g_string_free(signature, FALSE); +} + +static GtkWidget* native_schedule_filter_account_group( + NativeScheduleFilterControls* controls, + const gchar* title) { + GtkWidget* group = hdy_preferences_group_new(); + gtk_widget_set_hexpand(group, TRUE); + gtk_widget_set_halign(group, GTK_ALIGN_FILL); + hdy_preferences_group_set_title(HDY_PREFERENCES_GROUP(group), title); + gtk_box_pack_start(GTK_BOX(controls->sources_box), group, FALSE, FALSE, 0); + return group; +} + +static void native_schedule_filter_add_source_row( + NativeScheduleFilterControls* controls, + GtkWidget* group, + FlValue* item, + gboolean is_task_list) { + auto* source_row = g_new0(NativeScheduleFilterSourceRow, 1); + source_row->group = group; + source_row->is_task_list = is_task_list; + source_row->account_id = + g_strdup(fl_lookup_string_arg(item, "accountId")); + source_row->source_id = is_task_list + ? nullptr + : g_strdup(fl_lookup_string_arg(item, "id")); + source_row->task_list_id = + is_task_list ? g_strdup(fl_lookup_string_arg(item, "taskListId")) + : nullptr; + source_row->row = hdy_action_row_new(); + hdy_preferences_row_set_title( + HDY_PREFERENCES_ROW(source_row->row), + fl_lookup_string_arg(item, "title")); + source_row->check_button = gtk_check_button_new(); + atk_object_set_name( + gtk_widget_get_accessible(source_row->check_button), + fl_lookup_string_arg(item, "title")); + g_object_set_data(G_OBJECT(source_row->check_button), + "busymax-filter-controls", controls); + gtk_container_add(GTK_CONTAINER(source_row->row), + source_row->check_button); + hdy_action_row_set_activatable_widget(HDY_ACTION_ROW(source_row->row), + source_row->check_button); + gtk_list_box_row_set_activatable(GTK_LIST_BOX_ROW(source_row->row), TRUE); + gtk_container_add(GTK_CONTAINER(group), source_row->row); + g_ptr_array_add(controls->source_rows, source_row); + g_signal_connect(source_row->check_button, "toggled", + G_CALLBACK(native_schedule_filter_source_toggled_cb), + source_row); +} + +static void native_schedule_filter_rebuild_sources( + NativeScheduleFilterControls* controls, + FlValue* state) { + g_autofree gchar* signature = + native_schedule_filter_topology_signature(state); + if (g_strcmp0(signature, controls->topology_signature) == 0) return; + g_free(controls->topology_signature); + controls->topology_signature = g_strdup(signature); + GList* children = gtk_container_get_children( + GTK_CONTAINER(controls->sources_box)); + for (GList* child = children; child != nullptr; child = child->next) { + gtk_widget_destroy(GTK_WIDGET(child->data)); + } + g_list_free(children); + g_ptr_array_set_size(controls->source_rows, 0); + + FlValue* accounts = fl_lookup_list_arg(state, "accounts"); + FlValue* calendar_sources = fl_lookup_list_arg(state, "calendarSources"); + FlValue* task_lists = fl_lookup_list_arg(state, "taskLists"); + for (size_t account_index = 0; + account_index < fl_value_get_length(accounts); account_index++) { + FlValue* account = fl_value_get_list_value(accounts, account_index); + const gchar* account_id = fl_lookup_string_arg(account, "id"); + GtkWidget* group = nullptr; + for (size_t index = 0; index < fl_value_get_length(calendar_sources); + index++) { + FlValue* source = fl_value_get_list_value(calendar_sources, index); + if (g_strcmp0(account_id, + fl_lookup_string_arg(source, "accountId")) != 0) { + continue; + } + if (group == nullptr) { + group = native_schedule_filter_account_group( + controls, fl_lookup_string_arg(account, "label")); + } + native_schedule_filter_add_source_row(controls, group, source, FALSE); + } + for (size_t index = 0; index < fl_value_get_length(task_lists); index++) { + FlValue* task_list = fl_value_get_list_value(task_lists, index); + if (g_strcmp0(account_id, + fl_lookup_string_arg(task_list, "accountId")) != 0) { + continue; + } + if (group == nullptr) { + group = native_schedule_filter_account_group( + controls, fl_lookup_string_arg(account, "label")); + } + native_schedule_filter_add_source_row(controls, group, task_list, TRUE); + } + } + gtk_widget_show_all(controls->sources_box); +} + +static gint native_schedule_filter_combo_index(const gchar* value, + const gchar* kind) { + if (g_strcmp0(kind, "type") == 0) { + if (g_strcmp0(value, "events") == 0) return 1; + if (g_strcmp0(value, "tasks") == 0) return 2; + return 0; + } + if (g_strcmp0(kind, "date") == 0) { + if (g_strcmp0(value, "today") == 0) return 1; + if (g_strcmp0(value, "tomorrow") == 0) return 2; + if (g_strcmp0(value, "thisWeek") == 0) return 3; + if (g_strcmp0(value, "custom") == 0) return 4; + return 0; + } + if (g_strcmp0(kind, "completion") == 0) { + if (g_strcmp0(value, "all") == 0) return 1; + if (g_strcmp0(value, "completed") == 0) return 2; + return 0; + } + if (g_strcmp0(value, "overdue") == 0) return 1; + if (g_strcmp0(value, "noDueDate") == 0) return 2; + return 0; +} + +static gchar* native_schedule_filter_labels_signature(FlValue* labels) { + GString* signature = g_string_new(nullptr); + constexpr const gchar* keys[] = { + "searchFilters", "type", "all", "events", + "tasks", "date", "anyDate", "today", + "tomorrow", "thisWeek", "customRange", "startDate", + "endDate", "taskStatus", "open", "completed", + "taskDue", "anyDueState", "overdue", "noDueDate", + "person", "location", "sources", "clearFilters", + "noSources"}; + for (const gchar* key : keys) { + const gchar* value = fl_lookup_string_arg(labels, key); + g_string_append_printf(signature, "%zu:%s;", strlen(value), value); + } + return g_string_free(signature, FALSE); +} + +static void native_schedule_filter_set_combo_labels( + GtkWidget* combo, + std::initializer_list labels) { + gtk_combo_box_text_remove_all(GTK_COMBO_BOX_TEXT(combo)); + for (const gchar* label : labels) { + gtk_combo_box_text_append_text(GTK_COMBO_BOX_TEXT(combo), label); + } +} + +static void native_schedule_filter_apply_labels( + NativeScheduleFilterControls* controls, + FlValue* labels) { + g_autofree gchar* signature = + native_schedule_filter_labels_signature(labels); + const gboolean combo_labels_changed = + g_strcmp0(signature, controls->labels_signature) != 0; + if (combo_labels_changed) { + g_free(controls->labels_signature); + controls->labels_signature = g_strdup(signature); + native_schedule_filter_set_combo_labels( + controls->type_combo, + {fl_lookup_string_arg(labels, "all"), + fl_lookup_string_arg(labels, "events"), + fl_lookup_string_arg(labels, "tasks")}); + native_schedule_filter_set_combo_labels( + controls->date_combo, + {fl_lookup_string_arg(labels, "anyDate"), + fl_lookup_string_arg(labels, "today"), + fl_lookup_string_arg(labels, "tomorrow"), + fl_lookup_string_arg(labels, "thisWeek"), + fl_lookup_string_arg(labels, "customRange")}); + native_schedule_filter_set_combo_labels( + controls->task_completion_combo, + {fl_lookup_string_arg(labels, "open"), + fl_lookup_string_arg(labels, "all"), + fl_lookup_string_arg(labels, "completed")}); + native_schedule_filter_set_combo_labels( + controls->task_due_combo, + {fl_lookup_string_arg(labels, "anyDueState"), + fl_lookup_string_arg(labels, "overdue"), + fl_lookup_string_arg(labels, "noDueDate")}); + } + gtk_label_set_text(GTK_LABEL(controls->title_label), + fl_lookup_string_arg(labels, "searchFilters")); + hdy_preferences_row_set_title(HDY_PREFERENCES_ROW(controls->type_row), + fl_lookup_string_arg(labels, "type")); + hdy_preferences_row_set_title(HDY_PREFERENCES_ROW(controls->date_row), + fl_lookup_string_arg(labels, "date")); + hdy_preferences_row_set_title( + HDY_PREFERENCES_ROW(controls->start_date_row), + fl_lookup_string_arg(labels, "startDate")); + hdy_preferences_row_set_title(HDY_PREFERENCES_ROW(controls->end_date_row), + fl_lookup_string_arg(labels, "endDate")); + hdy_preferences_row_set_title( + HDY_PREFERENCES_ROW(controls->task_completion_row), + fl_lookup_string_arg(labels, "taskStatus")); + hdy_preferences_row_set_title(HDY_PREFERENCES_ROW(controls->task_due_row), + fl_lookup_string_arg(labels, "taskDue")); + hdy_preferences_row_set_title(HDY_PREFERENCES_ROW(controls->person_row), + fl_lookup_string_arg(labels, "person")); + hdy_preferences_row_set_title(HDY_PREFERENCES_ROW(controls->location_row), + fl_lookup_string_arg(labels, "location")); + atk_object_set_name(gtk_widget_get_accessible(controls->type_combo), + fl_lookup_string_arg(labels, "type")); + atk_object_set_name(gtk_widget_get_accessible(controls->date_combo), + fl_lookup_string_arg(labels, "date")); + atk_object_set_name( + gtk_widget_get_accessible(controls->task_completion_combo), + fl_lookup_string_arg(labels, "taskStatus")); + atk_object_set_name(gtk_widget_get_accessible(controls->task_due_combo), + fl_lookup_string_arg(labels, "taskDue")); + gtk_entry_set_placeholder_text(GTK_ENTRY(controls->person_entry), + fl_lookup_string_arg(labels, "person")); + gtk_entry_set_placeholder_text(GTK_ENTRY(controls->location_entry), + fl_lookup_string_arg(labels, "location")); + atk_object_set_name(gtk_widget_get_accessible(controls->person_entry), + fl_lookup_string_arg(labels, "person")); + atk_object_set_name(gtk_widget_get_accessible(controls->location_entry), + fl_lookup_string_arg(labels, "location")); + gtk_label_set_text(GTK_LABEL(controls->sources_label), + fl_lookup_string_arg(labels, "sources")); + gtk_label_set_text(GTK_LABEL(controls->no_sources_label), + fl_lookup_string_arg(labels, "noSources")); + gtk_button_set_label(GTK_BUTTON(controls->clear_button), + fl_lookup_string_arg(labels, "clearFilters")); + atk_object_set_name(gtk_widget_get_accessible(controls->clear_button), + fl_lookup_string_arg(labels, "clearFilters")); +} + +static gboolean native_schedule_filter_item_selected( + FlValue* state, + NativeScheduleFilterSourceRow* row) { + FlValue* list = fl_lookup_list_arg( + state, row->is_task_list ? "taskLists" : "calendarSources"); + for (size_t index = 0; index < fl_value_get_length(list); index++) { + FlValue* item = fl_value_get_list_value(list, index); + const gboolean matches = row->is_task_list + ? g_strcmp0(row->account_id, + fl_lookup_string_arg(item, "accountId")) == 0 && + g_strcmp0(row->task_list_id, + fl_lookup_string_arg(item, "taskListId")) == 0 + : g_strcmp0(row->source_id, fl_lookup_string_arg(item, "id")) == 0; + if (matches) { + return fl_lookup_bool_arg(item, "selected", FALSE); + } + } + return FALSE; +} + +static void apply_native_schedule_filter_state_to_controls( + NativeScheduleFilterControls* controls, + FlValue* state) { + FlValue* criteria = fl_lookup_map_arg(state, "criteria"); + FlValue* labels = fl_lookup_map_arg(state, "labels"); + if (criteria == nullptr || labels == nullptr) return; + MyApplication* self = controls->application; + self->suppress_schedule_filter_events = TRUE; + native_schedule_filter_apply_labels(controls, labels); + native_schedule_filter_rebuild_sources(controls, state); + + const gchar* type = fl_lookup_string_arg(criteria, "type"); + const gchar* date = fl_lookup_string_arg(criteria, "date"); + gtk_combo_box_set_active( + GTK_COMBO_BOX(controls->type_combo), + native_schedule_filter_combo_index(type, "type")); + gtk_combo_box_set_active( + GTK_COMBO_BOX(controls->date_combo), + native_schedule_filter_combo_index(date, "date")); + gtk_combo_box_set_active( + GTK_COMBO_BOX(controls->task_completion_combo), + native_schedule_filter_combo_index( + fl_lookup_string_arg(criteria, "taskCompletion"), "completion")); + gtk_combo_box_set_active( + GTK_COMBO_BOX(controls->task_due_combo), + native_schedule_filter_combo_index( + fl_lookup_string_arg(criteria, "taskDueState"), "due")); + + const gchar* person = fl_lookup_string_arg(criteria, "person"); + const gchar* location = fl_lookup_string_arg(criteria, "location"); + if (g_strcmp0(gtk_entry_get_text(GTK_ENTRY(controls->person_entry)), + person) != 0) { + gtk_entry_set_text(GTK_ENTRY(controls->person_entry), person); + } + if (g_strcmp0(gtk_entry_get_text(GTK_ENTRY(controls->location_entry)), + location) != 0) { + gtk_entry_set_text(GTK_ENTRY(controls->location_entry), location); + } + + const gchar* start = native_schedule_filter_date_value(criteria, + "customStart"); + const gchar* end = native_schedule_filter_date_value(criteria, "customEnd"); + const gchar* reference = native_schedule_filter_date_value( + criteria, "referenceDate"); + g_autofree gchar* start_label = + native_schedule_filter_format_date(start == nullptr ? reference : start); + g_autofree gchar* end_label = + native_schedule_filter_format_date(end == nullptr ? reference : end); + gtk_button_set_label(GTK_BUTTON(controls->start_date_button), start_label); + gtk_button_set_label(GTK_BUTTON(controls->end_date_button), end_label); + atk_object_set_name( + gtk_widget_get_accessible(controls->start_date_button), + fl_lookup_string_arg(labels, "startDate")); + atk_object_set_name(gtk_widget_get_accessible(controls->end_date_button), + fl_lookup_string_arg(labels, "endDate")); + + const gboolean includes_events = g_strcmp0(type, "tasks") != 0; + const gboolean includes_tasks = g_strcmp0(type, "events") != 0; + const gboolean custom_date = g_strcmp0(date, "custom") == 0; + gtk_widget_set_visible(controls->start_date_row, custom_date); + gtk_widget_set_visible(controls->end_date_row, custom_date); + gtk_widget_set_visible(controls->task_completion_row, includes_tasks); + gtk_widget_set_visible(controls->task_due_row, includes_tasks); + gtk_widget_set_visible(controls->person_row, includes_events); + + gboolean has_selected_source = FALSE; + for (guint index = 0; index < controls->source_rows->len; index++) { + auto* source_row = static_cast( + g_ptr_array_index(controls->source_rows, index)); + gtk_widget_set_visible(source_row->group, FALSE); + } + for (guint index = 0; index < controls->source_rows->len; index++) { + auto* source_row = static_cast( + g_ptr_array_index(controls->source_rows, index)); + const gboolean visible = + source_row->is_task_list ? includes_tasks : includes_events; + const gboolean selected = + native_schedule_filter_item_selected(state, source_row); + gtk_toggle_button_set_active( + GTK_TOGGLE_BUTTON(source_row->check_button), selected); + gtk_widget_set_visible(source_row->row, visible); + if (visible) gtk_widget_set_visible(source_row->group, TRUE); + has_selected_source = has_selected_source || (visible && selected); + } + gtk_widget_set_visible(controls->no_sources_label, !has_selected_source); + self->suppress_schedule_filter_events = FALSE; +} + +static void hide_native_schedule_filter_modal(MyApplication* self) { + NativeScheduleFilterControls* controls = self->schedule_filter_modal; + if (controls != nullptr && controls->window != nullptr && + GTK_IS_WIDGET(controls->window)) { + gtk_widget_destroy(controls->window); + } +} + +static NativeScheduleFilterControls* ensure_native_schedule_filter_sidebar( + MyApplication* self) { + if (self->schedule_filter_sidebar != nullptr) { + return self->schedule_filter_sidebar; + } + if (self->content_overlay == nullptr || + !GTK_IS_OVERLAY(self->content_overlay)) { + return nullptr; + } + NativeScheduleFilterControls* controls = + create_native_schedule_filter_controls(self, FALSE); + self->schedule_filter_sidebar = controls; + gtk_widget_set_halign(controls->root, GTK_ALIGN_START); + gtk_widget_set_valign(controls->root, GTK_ALIGN_FILL); + gtk_overlay_add_overlay(GTK_OVERLAY(self->content_overlay), controls->root); + gtk_overlay_set_overlay_pass_through(GTK_OVERLAY(self->content_overlay), + controls->root, FALSE); + return controls; +} + +static void apply_native_schedule_filter_state(MyApplication* self) { + FlValue* state = self->schedule_filter_state; + if (state == nullptr) return; + const gboolean active = fl_lookup_bool_arg(state, "active", FALSE); + const gboolean sidebar_visible = + active && fl_lookup_bool_arg(state, "sidebarVisible", FALSE); + NativeScheduleFilterControls* sidebar = + ensure_native_schedule_filter_sidebar(self); + if (sidebar != nullptr) { + gdouble sidebar_width = 300; + fl_lookup_double_arg(state, "sidebarWidth", &sidebar_width); + gtk_widget_set_size_request( + sidebar->root, static_cast(sidebar_width), -1); + if (active) { + gtk_widget_show_all(sidebar->root); + apply_native_schedule_filter_state_to_controls(sidebar, state); + } + gtk_widget_set_visible(sidebar->root, sidebar_visible); + } + if (!active) { + hide_native_schedule_filter_modal(self); + return; + } + if (sidebar_visible) { + hide_native_schedule_filter_modal(self); + } + if (self->schedule_filter_modal != nullptr) { + apply_native_schedule_filter_state_to_controls( + self->schedule_filter_modal, state); + } +} + +static gboolean native_schedule_filter_modal_key_press_cb(GtkWidget* window, + GdkEventKey* event, + gpointer) { + if (event->keyval != GDK_KEY_Escape) return FALSE; + gtk_widget_destroy(window); + return TRUE; +} + +static gboolean show_native_schedule_filter_modal(MyApplication* self) { + if (self->schedule_filter_state == nullptr || + !fl_lookup_bool_arg(self->schedule_filter_state, "active", FALSE) || + self->main_window == nullptr) { + return FALSE; + } + if (self->schedule_filter_modal != nullptr) { + gtk_window_present(GTK_WINDOW(self->schedule_filter_modal->window)); + return TRUE; + } + NativeScheduleFilterControls* controls = + create_native_schedule_filter_controls(self, TRUE); + self->schedule_filter_modal = controls; + GtkWidget* window = hdy_window_new(); + controls->window = window; + style_native_dialog(window); + gtk_window_set_transient_for(GTK_WINDOW(window), self->main_window); + gtk_window_set_modal(GTK_WINDOW(window), TRUE); + gtk_window_set_destroy_with_parent(GTK_WINDOW(window), TRUE); + gtk_window_set_type_hint(GTK_WINDOW(window), GDK_WINDOW_TYPE_HINT_DIALOG); + gtk_window_set_skip_taskbar_hint(GTK_WINDOW(window), TRUE); + gtk_window_set_skip_pager_hint(GTK_WINDOW(window), TRUE); + gtk_window_set_position(GTK_WINDOW(window), GTK_WIN_POS_CENTER_ON_PARENT); + gtk_window_set_default_size(GTK_WINDOW(window), + kNativeScheduleSearchFilterDialogWidth, + kNativeScheduleSearchFilterDialogHeight); + gtk_window_set_resizable(GTK_WINDOW(window), TRUE); + + FlValue* labels = + fl_lookup_map_arg(self->schedule_filter_state, "labels"); + const gchar* title = fl_lookup_string_arg(labels, "searchFilters"); + gtk_window_set_title(GTK_WINDOW(window), title); + GtkWidget* window_root = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0); + gtk_container_add(GTK_CONTAINER(window), window_root); + GtkWidget* header = hdy_header_bar_new(); + hdy_header_bar_set_title(HDY_HEADER_BAR(header), title); + hdy_header_bar_set_has_subtitle(HDY_HEADER_BAR(header), FALSE); + hdy_header_bar_set_show_close_button(HDY_HEADER_BAR(header), TRUE); + hdy_header_bar_set_decoration_layout(HDY_HEADER_BAR(header), ":close"); + gtk_box_pack_start(GTK_BOX(window_root), header, FALSE, FALSE, 0); + gtk_box_pack_start(GTK_BOX(window_root), controls->root, TRUE, TRUE, 0); + g_signal_connect(window, "key-press-event", + G_CALLBACK(native_schedule_filter_modal_key_press_cb), + nullptr); + gtk_widget_show_all(window); + apply_native_schedule_filter_state_to_controls( + controls, self->schedule_filter_state); + gtk_widget_grab_focus(controls->type_combo); + gtk_window_present(GTK_WINDOW(window)); + return TRUE; +} + +static void native_schedule_search_filters_method_call_cb( + FlMethodChannel*, + FlMethodCall* method_call, + gpointer user_data) { + MyApplication* self = MY_APPLICATION(user_data); + const gchar* method = fl_method_call_get_name(method_call); + FlValue* args = fl_method_call_get_args(method_call); + if (g_strcmp0(method, "initialize") == 0) { + respond_bool(method_call, self->content_overlay != nullptr); + } else if (g_strcmp0(method, "setState") == 0) { + const gchar* error = nullptr; + if (!validate_native_schedule_filter_state(args, &error)) { + fl_method_call_respond_error(method_call, "invalid-arguments", error, + nullptr, nullptr); + return; + } + g_clear_pointer(&self->schedule_filter_state, fl_value_unref); + self->schedule_filter_state = fl_value_ref(args); + apply_native_schedule_filter_state(self); + respond_success(method_call); + } else if (g_strcmp0(method, "showModal") == 0) { + respond_bool(method_call, show_native_schedule_filter_modal(self)); + } else if (g_strcmp0(method, "hideModal") == 0) { + hide_native_schedule_filter_modal(self); + respond_success(method_call); + } else if (g_strcmp0(method, "hide") == 0) { + if (self->schedule_filter_sidebar != nullptr) { + gtk_widget_hide(self->schedule_filter_sidebar->root); + } + hide_native_schedule_filter_modal(self); + g_clear_pointer(&self->schedule_filter_state, fl_value_unref); + respond_success(method_call); + } else { + fl_method_call_respond_not_implemented(method_call, nullptr); + } +} + +static void register_native_schedule_search_filters(MyApplication* self, + FlView* view) { + g_autoptr(FlStandardMethodCodec) codec = fl_standard_method_codec_new(); + self->native_schedule_search_filters_channel = fl_method_channel_new( + fl_engine_get_binary_messenger(fl_view_get_engine(view)), + kNativeScheduleSearchFiltersChannel, FL_METHOD_CODEC(codec)); + fl_method_channel_set_method_call_handler( + self->native_schedule_search_filters_channel, + native_schedule_search_filters_method_call_cb, self, nullptr); +} + static gboolean fl_method_bool_arg(FlValue* args) { return args != nullptr && fl_value_get_type(args) == FL_VALUE_TYPE_BOOL ? fl_value_get_bool(args) @@ -3479,6 +4612,41 @@ static gboolean focus_header_search_entry(MyApplication* self) { return gtk_widget_has_focus(self->search_entry); } +static gboolean native_schedule_filter_window_key_press_cb( + GtkWindow* window, + GdkEventKey* event, + gpointer user_data) { + MyApplication* self = MY_APPLICATION(user_data); + if (self->schedule_filter_state == nullptr || + !fl_lookup_bool_arg(self->schedule_filter_state, "active", FALSE) || + self->schedule_filter_modal != nullptr) { + return FALSE; + } + const GdkModifierType modifiers = static_cast( + event->state & gtk_accelerator_get_default_mod_mask()); + if ((event->keyval == GDK_KEY_f || event->keyval == GDK_KEY_F) && + (modifiers & GDK_CONTROL_MASK) != 0) { + return focus_header_search_entry(self); + } + if (event->keyval == GDK_KEY_F9) { + invoke_header_bar_action(self, "sidebarToggle"); + return TRUE; + } + if (event->keyval != GDK_KEY_Escape || + self->schedule_filter_sidebar == nullptr || + !gtk_widget_get_visible(self->schedule_filter_sidebar->root)) { + return FALSE; + } + GtkWidget* focus = gtk_window_get_focus(window); + if (focus == nullptr || + !gtk_widget_is_ancestor(focus, + self->schedule_filter_sidebar->root)) { + return FALSE; + } + invoke_native_schedule_filter_event(self, "dismissSearch", nullptr); + return TRUE; +} + static void set_header_search_state(MyApplication* self, gboolean active, const gchar* query) { @@ -5077,6 +6245,9 @@ static void my_application_activate(GApplication* application) { g_signal_connect( window, "notify::is-active", G_CALLBACK(header_focus_window_is_active_notify_cb), self); + g_signal_connect(window, "key-press-event", + G_CALLBACK(native_schedule_filter_window_key_press_cb), + self); g_autoptr(FlDartProject) project = fl_dart_project_new(); fl_dart_project_set_dart_entrypoint_arguments( @@ -5090,7 +6261,13 @@ static void my_application_activate(GApplication* application) { GtkWidget* window_content = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0); gtk_box_pack_start(GTK_BOX(window_content), titlebar_handle, FALSE, FALSE, 0); - gtk_box_pack_start(GTK_BOX(window_content), GTK_WIDGET(view), TRUE, TRUE, 0); + track_widget_pointer(&self->content_overlay, gtk_overlay_new()); + gtk_widget_set_hexpand(self->content_overlay, TRUE); + gtk_widget_set_vexpand(self->content_overlay, TRUE); + gtk_container_add(GTK_CONTAINER(self->content_overlay), GTK_WIDGET(view)); + gtk_widget_show(self->content_overlay); + gtk_box_pack_start(GTK_BOX(window_content), self->content_overlay, TRUE, TRUE, + 0); gtk_widget_show(window_content); gtk_container_add(GTK_CONTAINER(window), window_content); @@ -5104,6 +6281,7 @@ static void my_application_activate(GApplication* application) { register_native_date_time_picker(self, view, window); register_native_dialogs(self, view, window); register_native_menus(self, view); + register_native_schedule_search_filters(self, view); register_external_calendar_open_channel(self, view); register_external_uri_launcher_channel(self, view); register_window_channel(self, view); @@ -5197,6 +6375,13 @@ static void my_application_shutdown(GApplication* application) { static void my_application_dispose(GObject* object) { MyApplication* self = MY_APPLICATION(object); disconnect_gtk_theme_colors_signals(self); + hide_native_schedule_filter_modal(self); + if (self->schedule_filter_sidebar != nullptr && + self->schedule_filter_sidebar->root != nullptr && + GTK_IS_WIDGET(self->schedule_filter_sidebar->root)) { + gtk_widget_destroy(self->schedule_filter_sidebar->root); + } + g_clear_pointer(&self->schedule_filter_state, fl_value_unref); if (self->header_bar_css_provider != nullptr) { gtk_style_context_remove_provider_for_screen( gdk_screen_get_default(), GTK_STYLE_PROVIDER(self->header_bar_css_provider)); @@ -5205,6 +6390,7 @@ static void my_application_dispose(GObject* object) { g_clear_object(&self->native_date_time_picker_channel); g_clear_object(&self->native_dialog_channel); g_clear_object(&self->native_menu_channel); + g_clear_object(&self->native_schedule_search_filters_channel); g_clear_object(&self->window_channel); g_clear_object(&self->header_bar_channel); g_clear_object(&self->gtk_settings_channel); @@ -5230,6 +6416,7 @@ static void my_application_dispose(GObject* object) { } self->main_window = nullptr; clear_widget_pointer(&self->flutter_view); + clear_widget_pointer(&self->content_overlay); clear_widget_pointer(&self->titlebar_handle); clear_widget_pointer(&self->titlebar_overlay); clear_widget_pointer(&self->titlebar_modal_barrier); @@ -5322,6 +6509,7 @@ static void my_application_init(MyApplication* self) { self->native_date_time_picker_channel = nullptr; self->native_dialog_channel = nullptr; self->native_menu_channel = nullptr; + self->native_schedule_search_filters_channel = nullptr; self->window_channel = nullptr; self->header_bar_channel = nullptr; self->gtk_settings_channel = nullptr; @@ -5375,6 +6563,7 @@ static void my_application_init(MyApplication* self) { self->main_window = nullptr; self->header_focus_transient_window = nullptr; self->flutter_view = nullptr; + self->content_overlay = nullptr; self->titlebar_handle = nullptr; self->titlebar_overlay = nullptr; self->titlebar_modal_barrier = nullptr; @@ -5438,6 +6627,10 @@ static void my_application_init(MyApplication* self) { self->header_search_query = g_strdup(""); self->header_search_active = FALSE; self->header_navigation_visible = TRUE; + self->schedule_filter_sidebar = nullptr; + self->schedule_filter_modal = nullptr; + self->schedule_filter_state = nullptr; + self->suppress_schedule_filter_events = FALSE; } MyApplication* my_application_new() { diff --git a/test/app/native_ui_audit_test.dart b/test/app/native_ui_audit_test.dart index b0114b2d..6363cf1c 100644 --- a/test/app/native_ui_audit_test.dart +++ b/test/app/native_ui_audit_test.dart @@ -1344,6 +1344,56 @@ void main() { expect(confirmBody, isNot(contains('return AlertDialog('))); }); + test('schedule search filters are native GTK and Handy on Linux', () { + final runner = File('linux/runner/my_application.cc').readAsStringSync(); + final workspace = File( + 'lib/src/features/schedule/presentation/schedule_workspace.dart', + ).readAsStringSync(); + final service = File( + 'lib/src/platform/linux_schedule_search_filter_service.dart', + ).readAsStringSync(); + + expect(runner, contains('busymax/native_schedule_search_filters')); + expect(runner, contains('gtk_overlay_new()')); + expect(runner, contains('gtk_overlay_add_overlay(')); + expect(runner, contains('gtk_overlay_set_overlay_pass_through(')); + expect(runner, contains('gtk_scrolled_window_new(')); + expect(runner, contains('hdy_preferences_group_new()')); + expect(runner, contains('hdy_action_row_new()')); + expect(runner, contains('gtk_combo_box_text_new()')); + expect(runner, contains('gtk_search_entry_new()')); + expect(runner, contains('gtk_check_button_new()')); + expect( + runner, + contains( + 'gtk_style_context_add_class(gtk_widget_get_style_context(controls->root),\n' + ' GTK_STYLE_CLASS_BACKGROUND);', + ), + ); + expect( + runner, + isNot( + contains( + 'gtk_style_context_add_class(gtk_widget_get_style_context(controls->root),\n' + ' "sidebar");', + ), + ), + ); + expect(runner, contains('show_native_schedule_filter_modal')); + expect(runner, contains('run_native_date_picker(')); + expect(runner, contains('self->suppress_schedule_filter_events = TRUE')); + expect(service, contains('class LinuxScheduleSearchFilterService')); + expect(workspace, contains('linux-native-search-filter-spacer')); + expect(workspace, isNot(contains('ScheduleSearchFilters('))); + expect(workspace, isNot(contains('showDialog('))); + expect( + File( + 'lib/src/features/schedule/presentation/schedule_search_filters.dart', + ).existsSync(), + isFalse, + ); + }); + test('timezone selection uses native GTK and Handy controls on Linux', () { final runner = File('linux/runner/my_application.cc').readAsStringSync(); final service = File( diff --git a/test/features/schedule/presentation/schedule_search_filters_test.dart b/test/features/schedule/presentation/schedule_search_filters_test.dart deleted file mode 100644 index 16309405..00000000 --- a/test/features/schedule/presentation/schedule_search_filters_test.dart +++ /dev/null @@ -1,94 +0,0 @@ -import 'package:busymax/l10n/generated/app_localizations.dart'; -import 'package:busymax/src/features/schedule/presentation/schedule_search_filters.dart'; -import 'package:busymax/src/features/tasks/presentation/desktop_date_time_fields.dart'; -import 'package:busymax/src/schedule/schedule_search_criteria.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:yaru/yaru.dart'; - -import '../../../test_localized_app.dart'; - -void main() { - testWidgets('Linux custom range uses native desktop date fields', ( - tester, - ) async { - const channel = MethodChannel(nativeDateTimePickerChannelName); - final calls = []; - final responses = ['2026-06-15', '2026-06-12', null]; - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, (call) async { - calls.add(call); - return responses.removeAt(0); - }); - addTearDown(() { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, null); - }); - - var criteria = ScheduleSearchCriteria( - referenceDate: DateTime(2026, 6, 12), - firstWeekday: DateTime.monday, - sourceIds: {}, - taskListKeys: {}, - date: ScheduleSearchDate.custom, - customStart: DateTime(2026, 6, 12), - customEnd: DateTime(2026, 6, 14), - ); - await tester.pumpWidget( - localizedTestApp( - child: Scaffold( - body: SizedBox( - width: 320, - child: StatefulBuilder( - builder: (context, setState) => ScheduleSearchFilters( - value: criteria, - onChanged: (value) => setState(() => criteria = value), - onClear: () {}, - accounts: const [], - sources: const [], - taskLists: const [], - ), - ), - ), - ), - ), - ); - expect(find.byType(DesktopDateField), findsNWidgets(2)); - final labels = AppLocalizations.of( - tester.element(find.byType(ScheduleSearchFilters)), - ); - - Future pickDate(String label) async { - final field = find.byWidgetPredicate( - (widget) => widget is DesktopDateField && widget.label == label, - ); - final button = find.descendant( - of: field, - matching: find.byType(YaruIconButton), - ); - await tester.ensureVisible(button); - await tester.tap(button); - await tester.pumpAndSettle(); - } - - await pickDate(labels.startDate); - expect(calls.single.method, 'pickDate'); - expect(calls.single.arguments, containsPair('initialDate', '2026-06-12')); - expect(criteria.customStart, DateTime(2026, 6, 15)); - expect(criteria.customEnd, DateTime(2026, 6, 15)); - expect(criteria.range!.end, DateTime(2026, 6, 16)); - - await pickDate(labels.endDate); - expect(calls.last.arguments, containsPair('initialDate', '2026-06-15')); - expect(criteria.customStart, DateTime(2026, 6, 12)); - expect(criteria.customEnd, DateTime(2026, 6, 12)); - expect(criteria.range!.end, DateTime(2026, 6, 13)); - - final beforeCancel = criteria; - await pickDate(labels.endDate); - expect(criteria, beforeCancel); - expect(find.byType(DatePickerDialog), findsNothing); - expect(tester.takeException(), isNull); - }); -} diff --git a/test/features/schedule/presentation/schedule_search_platform_test.dart b/test/features/schedule/presentation/schedule_search_platform_test.dart index 6161d883..63ed8bbc 100644 --- a/test/features/schedule/presentation/schedule_search_platform_test.dart +++ b/test/features/schedule/presentation/schedule_search_platform_test.dart @@ -6,15 +6,14 @@ import 'package:busymax/src/calendar_providers/calendar_sync_dto.dart'; import 'package:busymax/src/db/app_database.dart'; import 'package:busymax/src/features/accounts/data/accounts_repository.dart'; import 'package:busymax/src/features/calendar/data/calendar_repository.dart'; -import 'package:busymax/src/features/schedule/presentation/schedule_agenda_view.dart'; import 'package:busymax/src/features/schedule/presentation/schedule_month_view.dart'; -import 'package:busymax/src/features/schedule/presentation/schedule_search_filters.dart'; import 'package:busymax/src/features/schedule/presentation/schedule_sidebar.dart'; import 'package:busymax/src/features/schedule/presentation/schedule_workspace.dart'; import 'package:busymax/src/features/task_lists/data/task_lists_repository.dart'; import 'package:busymax/src/features/tasks/data/tasks_repository.dart'; import 'package:busymax/src/platform/linux_header_bar_provider.dart'; import 'package:busymax/src/platform/linux_header_bar_service.dart'; +import 'package:busymax/src/platform/linux_schedule_search_filter_service.dart'; import 'package:busymax/src/providers/busy_provider.dart'; import 'package:busymax/src/schedule/schedule_filters.dart'; import 'package:busymax/src/schedule/schedule_repository.dart'; @@ -26,7 +25,6 @@ import 'package:busymax/src/ui/windows/windows_schedule_source_pane.dart'; import 'package:drift/drift.dart' hide Column, isNull, isNotNull; import 'package:fluent_ui/fluent_ui.dart' as fluent; import 'package:flutter/material.dart'; -import 'package:yaru/yaru.dart'; import 'package:busymax/src/android/presentation/android_schedule_search_filters.dart'; import 'package:busymax/src/features/connectivity/network_connectivity_service.dart'; import 'package:flutter/services.dart'; @@ -50,9 +48,21 @@ void main() { await _searchShortcut(tester); expect(normal, findsNothing); final panelFinder = platform == 'linux' - ? find.byType(ScheduleSearchFilters) + ? find.byKey(const ValueKey('linux-native-search-filter-spacer')) : find.byType(WindowsScheduleSearchPane); expect(panelFinder, findsOneWidget); + if (platform == 'linux') { + expect(find.byType(Dialog), findsNothing); + expect( + fixture.filterCalls + .lastWhere((call) => call.method == 'setState') + .arguments, + allOf( + containsPair('active', true), + containsPair('sidebarVisible', true), + ), + ); + } final list = find.byKey( ValueKey( platform == 'linux' @@ -64,19 +74,14 @@ void main() { expect(find.text('Far future review'), findsOneWidget); expect(find.text('No due task'), findsOneWidget); if (platform == 'linux') { - expect( - tester - .widget(find.byType(ScheduleAgendaView)) - .searchCriteria, - isNotNull, - ); + expect(_nativeCriteria(fixture.filterCalls), isNotNull); } ScheduleSearchCriteria criteria() => platform == 'linux' - ? tester.widget(panelFinder).value + ? _nativeCriteria(fixture.filterCalls) : tester.widget(panelFinder).value; - void change(ScheduleSearchCriteria value) { + Future change(ScheduleSearchCriteria value) async { if (platform == 'linux') { - tester.widget(panelFinder).onChanged(value); + await _applyLinuxCriteria(fixture.filters, criteria(), value); } else { tester .widget(panelFinder) @@ -85,7 +90,7 @@ void main() { } final initial = criteria(); - change( + await change( criteria().copyWith( type: ScheduleSearchType.tasks, taskCompletion: ScheduleTaskCompletion.open, @@ -99,20 +104,25 @@ void main() { expect(find.text('Far future review'), findsNothing); // Exercise the actual source checkbox, then verify repository/settings stayed intact. final checkbox = platform == 'linux' - ? find - .descendant( - of: panelFinder, - matching: find.byType(YaruCheckboxListTile), - ) - .first + ? null : find .descendant( of: panelFinder, matching: find.byType(fluent.Checkbox), ) .first; - await tester.ensureVisible(checkbox); - await tester.tap(checkbox); + if (platform == 'linux') { + await fixture.filters.handleNativeMethodCall( + const MethodCall('taskListToggled', { + 'accountId': 'account', + 'taskListId': 'list', + 'selected': false, + }), + ); + } else { + await tester.ensureVisible(checkbox!); + await tester.tap(checkbox); + } await tester.pumpAndSettle(); expect(criteria().taskListKeys, isEmpty); expect(find.text('Overdue task'), findsNothing); @@ -128,27 +138,33 @@ void main() { isTrue, ); if (platform == 'linux') { - tester.widget(panelFinder).onClear(); + await fixture.filters.handleNativeMethodCall( + const MethodCall('clearFilters'), + ); } else { tester.widget(panelFinder).onClear(); } await tester.pumpAndSettle(); expect(criteria(), initial); final calendarToggle = platform == 'linux' - ? find - .descendant( - of: panelFinder, - matching: find.byType(YaruCheckboxListTile), - ) - .first + ? null : find .descendant( of: panelFinder, matching: find.byType(fluent.Checkbox), ) .first; - await tester.ensureVisible(calendarToggle); - await tester.tap(calendarToggle); + if (platform == 'linux') { + await fixture.filters.handleNativeMethodCall( + MethodCall('calendarSourceToggled', { + 'sourceId': criteria().sourceIds.single, + 'selected': false, + }), + ); + } else { + await tester.ensureVisible(calendarToggle!); + await tester.tap(calendarToggle); + } await tester.pumpAndSettle(); expect(criteria().sourceIds, isEmpty); expect( @@ -157,10 +173,19 @@ void main() { isTrue, ); expect(find.text('Far future review'), findsNothing); - await tester.tap(calendarToggle); + if (platform == 'linux') { + await fixture.filters.handleNativeMethodCall( + MethodCall('calendarSourceToggled', { + 'sourceId': initial.sourceIds.single, + 'selected': true, + }), + ); + } else { + await tester.tap(calendarToggle!); + } await tester.pumpAndSettle(); - change( + await change( criteria().copyWith( type: ScheduleSearchType.events, person: 'alex@example.com', @@ -168,7 +193,7 @@ void main() { ); await tester.pumpAndSettle(); expect(find.text('Far future review'), findsOneWidget); - change( + await change( criteria().copyWith( date: ScheduleSearchDate.custom, customStart: DateTime(2040, 2, 15), @@ -178,7 +203,7 @@ void main() { ); await tester.pumpAndSettle(); expect(find.text('Far future review'), findsOneWidget); - change( + await change( criteria().copyWith( customStart: DateTime(2040, 2, 16), customEnd: DateTime(2040, 2, 16), @@ -186,7 +211,7 @@ void main() { ); await tester.pumpAndSettle(); expect(find.text('Far future review'), findsNothing); - change(criteria().copyWith(date: ScheduleSearchDate.any)); + await change(criteria().copyWith(date: ScheduleSearchDate.any)); await tester.pumpAndSettle(); // Clearing only the entry retains Person and the temporary scope. final entry = platform == 'linux' @@ -220,6 +245,14 @@ void main() { await tester.pumpAndSettle(); expect(normal, findsOneWidget); expect(panelFinder, findsNothing); + if (platform == 'linux') { + expect( + fixture.filterCalls + .lastWhere((call) => call.method == 'setState') + .arguments, + containsPair('active', false), + ); + } expect( platform == 'linux' ? find.byType(ScheduleMonthView) @@ -238,12 +271,12 @@ void main() { testWidgets( '$platform narrow search opens full filters through sidebar action', (tester) async { - await _mount(tester, platform, width: 650); + final fixture = await _mount(tester, platform, width: 650); await tester.pumpAndSettle(); await _searchShortcut(tester); expect( platform == 'linux' - ? find.byType(ScheduleSearchFilters) + ? find.byKey(const ValueKey('linux-native-search-filter-spacer')) : find.byType(WindowsScheduleSearchPane), findsNothing, ); @@ -251,10 +284,16 @@ void main() { await tester.pumpAndSettle(); expect( platform == 'linux' - ? find.byType(ScheduleSearchFilters) + ? find.byType(Dialog) : find.byType(WindowsScheduleSearchPane), - findsOneWidget, + platform == 'linux' ? findsNothing : findsOneWidget, ); + if (platform == 'linux') { + expect( + fixture.filterCalls.where((call) => call.method == 'showModal'), + isNotEmpty, + ); + } expect( find.text('Custom range'), findsNothing, @@ -368,11 +407,15 @@ Future _searchShortcut(WidgetTester tester) async { await tester.pumpAndSettle(); } -Future<({AppDatabase db, ProviderContainer container})> _mount( - WidgetTester tester, - String platform, { - double width = 1280, -}) async { +Future< + ({ + AppDatabase db, + ProviderContainer container, + LinuxScheduleSearchFilterService filters, + List filterCalls, + }) +> +_mount(WidgetTester tester, String platform, {double width = 1280}) async { tester.view ..physicalSize = Size(width, 900) ..devicePixelRatio = 1; @@ -450,6 +493,22 @@ Future<({AppDatabase db, ProviderContainer container})> _mount( ); } final header = LinuxHeaderBarService(isLinux: false); + const filterChannel = MethodChannel( + 'busymax_test/schedule_search_platform_filters', + ); + final filterCalls = []; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(filterChannel, (call) async { + filterCalls.add(call); + if (call.method == 'initialize' || call.method == 'showModal') { + return true; + } + return null; + }); + final filters = LinuxScheduleSearchFilterService( + channel: filterChannel, + isLinux: platform == 'linux', + ); final container = ProviderContainer( overrides: [ networkAvailabilityProvider.overrideWith( @@ -458,6 +517,7 @@ Future<({AppDatabase db, ProviderContainer container})> _mount( databaseProvider.overrideWithValue(db), localTimeZoneProvider.overrideWithValue('UTC'), linuxHeaderBarServiceProvider.overrideWithValue(header), + linuxScheduleSearchFilterServiceProvider.overrideWithValue(filters), localSettingsStoreProvider.overrideWithValue(MemorySettingsStore()), initialAppSettingsProvider.overrideWithValue( AppSettings.defaults().copyWith( @@ -482,6 +542,9 @@ Future<({AppDatabase db, ProviderContainer container})> _mount( await tester.pumpWidget(const SizedBox.shrink()); container.dispose(); header.dispose(); + filters.dispose(); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(filterChannel, null); await db.close(); }); await tester.pumpWidget( @@ -500,5 +563,79 @@ Future<({AppDatabase db, ProviderContainer container})> _mount( ), ), ); - return (db: db, container: container); + return ( + db: db, + container: container, + filters: filters, + filterCalls: filterCalls, + ); +} + +Future _applyLinuxCriteria( + LinuxScheduleSearchFilterService service, + ScheduleSearchCriteria current, + ScheduleSearchCriteria next, +) async { + final calls = [ + if (current.type != next.type) MethodCall('typeChanged', next.type.name), + if (current.date != next.date) MethodCall('dateChanged', next.date.name), + if (current.taskCompletion != next.taskCompletion) + MethodCall('taskCompletionChanged', next.taskCompletion.name), + if (current.taskDueState != next.taskDueState) + MethodCall('taskDueStateChanged', next.taskDueState.name), + if (current.person != next.person) MethodCall('personChanged', next.person), + if (current.location != next.location) + MethodCall('locationChanged', next.location), + if (current.customStart != next.customStart && next.customStart != null) + MethodCall( + 'customStartChanged', + next.customStart!.toIso8601String().substring(0, 10), + ), + if (current.customEnd != next.customEnd && next.customEnd != null) + MethodCall( + 'customEndChanged', + next.customEnd!.toIso8601String().substring(0, 10), + ), + ]; + for (final call in calls) { + await service.handleNativeMethodCall(call); + } +} + +ScheduleSearchCriteria _nativeCriteria(List calls) { + final state = + calls.lastWhere((call) => call.method == 'setState').arguments + as Map; + final value = state['criteria']! as Map; + DateTime? date(String key) { + final encoded = value[key] as String?; + return encoded == null ? null : DateTime.parse(encoded); + } + + return ScheduleSearchCriteria( + type: ScheduleSearchType.values.byName(value['type']! as String), + date: ScheduleSearchDate.values.byName(value['date']! as String), + taskCompletion: ScheduleTaskCompletion.values.byName( + value['taskCompletion']! as String, + ), + taskDueState: ScheduleTaskDueState.values.byName( + value['taskDueState']! as String, + ), + person: value['person']! as String, + location: value['location']! as String, + customStart: date('customStart'), + customEnd: date('customEnd'), + referenceDate: date('referenceDate')!, + firstWeekday: value['firstWeekday']! as int, + sourceIds: { + for (final id in value['sourceIds']! as List) id! as String, + }, + taskListKeys: { + for (final item in value['taskListKeys']! as List) + ScheduleTaskListKey( + accountId: (item! as Map)['accountId']! as String, + taskListId: (item as Map)['taskListId']! as String, + ), + }, + ); } diff --git a/test/features/schedule/presentation/schedule_workspace_states_test.dart b/test/features/schedule/presentation/schedule_workspace_states_test.dart index e45da6b2..5e9f666f 100644 --- a/test/features/schedule/presentation/schedule_workspace_states_test.dart +++ b/test/features/schedule/presentation/schedule_workspace_states_test.dart @@ -8,10 +8,10 @@ import 'package:busymax/src/db/app_database.dart'; import 'package:busymax/src/features/accounts/data/accounts_repository.dart'; import 'package:busymax/src/features/schedule/presentation/schedule_empty_states.dart'; import 'package:busymax/src/features/schedule/presentation/schedule_sidebar.dart'; -import 'package:busymax/src/features/schedule/presentation/schedule_search_filters.dart'; import 'package:busymax/src/features/schedule/presentation/schedule_workspace.dart'; import 'package:busymax/src/platform/linux_header_bar_service.dart'; import 'package:busymax/src/platform/linux_header_bar_provider.dart'; +import 'package:busymax/src/platform/linux_schedule_search_filter_service.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter/services.dart'; @@ -255,11 +255,29 @@ void main() { channel: channel, isLinux: true, ); + const filterChannel = MethodChannel( + 'busymax_test/schedule_native_filters', + ); + final filterCalls = []; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(filterChannel, (call) async { + filterCalls.add(call); + return call.method == 'initialize' ? true : null; + }); + addTearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(filterChannel, null); + }); + final filterService = LinuxScheduleSearchFilterService( + channel: filterChannel, + isLinux: true, + ); await _pumpWorkspace( tester, accountsFactory: () => Stream.value(const []), headerBarService: headerBarService, + searchFilterService: filterService, ); await tester.pumpAndSettle(); @@ -281,17 +299,23 @@ void main() { containsPair('searchQuery', 'planning'), ); - await tester.tap(find.byType(TextField).first); - await tester.pumpAndSettle(); - expect(calls.where((call) => call.method == 'focusContent'), isNotEmpty); - await tester.enterText(find.byType(TextField).first, 'Taylor'); + expect( + find.byKey(const ValueKey('linux-native-search-filter-spacer')), + findsOneWidget, + ); + expect(find.byType(TextField), findsNothing); + await filterService.handleNativeMethodCall( + const MethodCall('personChanged', 'Taylor'), + ); await tester.pumpAndSettle(); + final nativeFilterState = + filterCalls.where((call) => call.method == 'setState').last.arguments + as Map; + expect(nativeFilterState['criteria'], containsPair('person', 'Taylor')); + expect(calls.where((call) => call.method == 'focusContent'), isEmpty); expect( - tester - .widget(find.byType(ScheduleSearchFilters)) - .value - .person, - 'Taylor', + filterCalls.where((call) => call.method == 'setState').last.arguments, + containsPair('active', true), ); expect( calls.where((call) => call.method == 'setState').last.arguments, @@ -333,7 +357,10 @@ void main() { await tester.sendKeyEvent(LogicalKeyboardKey.keyF); await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); await tester.pumpAndSettle(); - expect(find.byType(ScheduleSearchFilters), findsOneWidget); + expect( + find.byKey(const ValueKey('linux-native-search-filter-spacer')), + findsOneWidget, + ); await tester.sendKeyEvent(LogicalKeyboardKey.escape); await tester.pumpAndSettle(); expect(find.byType(ScheduleSidebar), findsOneWidget); @@ -362,12 +389,16 @@ Future _pumpWorkspace( WidgetTester tester, { required Stream> Function() accountsFactory, LinuxHeaderBarService? headerBarService, + LinuxScheduleSearchFilterService? searchFilterService, }) async { final database = AppDatabase.memoryForTests(); addTearDown(database.close); final resolvedHeaderBarService = headerBarService ?? LinuxHeaderBarService(isLinux: false); addTearDown(resolvedHeaderBarService.dispose); + final resolvedSearchFilterService = + searchFilterService ?? LinuxScheduleSearchFilterService(isLinux: false); + addTearDown(resolvedSearchFilterService.dispose); await tester.pumpWidget( ProviderScope( @@ -379,6 +410,9 @@ Future _pumpWorkspace( linuxHeaderBarServiceProvider.overrideWithValue( resolvedHeaderBarService, ), + linuxScheduleSearchFilterServiceProvider.overrideWithValue( + resolvedSearchFilterService, + ), ], child: localizedTestApp(child: const ScheduleWorkspace()), ), diff --git a/test/platform/linux_schedule_search_filter_service_test.dart b/test/platform/linux_schedule_search_filter_service_test.dart new file mode 100644 index 00000000..84caa724 --- /dev/null +++ b/test/platform/linux_schedule_search_filter_service_test.dart @@ -0,0 +1,272 @@ +import 'package:busymax/src/platform/linux_schedule_search_filter_service.dart'; +import 'package:busymax/src/schedule/schedule_filters.dart'; +import 'package:busymax/src/schedule/schedule_search_criteria.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + const channel = MethodChannel('busymax_test/native_schedule_filters'); + + tearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); + }); + + test('serializes complete localized presentation state', () async { + final calls = []; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + calls.add(call); + return call.method == 'initialize' ? true : null; + }); + final service = LinuxScheduleSearchFilterService( + channel: channel, + isLinux: true, + ); + addTearDown(service.dispose); + final session = service.claimSession(); + addTearDown(session.dispose); + + final criteria = ScheduleSearchCriteria( + type: ScheduleSearchType.tasks, + date: ScheduleSearchDate.custom, + taskCompletion: ScheduleTaskCompletion.completed, + taskDueState: ScheduleTaskDueState.noDueDate, + person: 'Taylor', + location: 'Office', + customStart: DateTime(2026, 9, 12), + customEnd: DateTime(2026, 9, 15), + referenceDate: DateTime(2026, 9, 10), + firstWeekday: DateTime.monday, + sourceIds: {'calendar-b'}, + taskListKeys: { + const ScheduleTaskListKey(accountId: 'account-a', taskListId: 'list-b'), + }, + ); + await session.updateState( + LinuxScheduleSearchFilterState( + active: true, + sidebarVisible: true, + sidebarWidth: 300, + criteria: criteria, + labels: _labels, + accounts: const [ + LinuxScheduleSearchFilterAccount(id: 'account-a', label: 'Alex'), + ], + calendarSources: const [ + LinuxScheduleSearchFilterCalendarSource( + id: 'calendar-b', + accountId: 'account-a', + title: 'Work', + selected: true, + ), + ], + taskLists: const [ + LinuxScheduleSearchFilterTaskList( + accountId: 'account-a', + taskListId: 'list-b', + title: 'Tasks', + selected: true, + ), + ], + ), + ); + + final state = + calls.singleWhere((call) => call.method == 'setState').arguments + as Map; + expect(state, containsPair('schemaVersion', 1)); + expect(state, containsPair('active', true)); + expect(state['labels'], containsPair('searchFilters', 'Search filters')); + expect(state['labels'], containsPair('noDueDate', 'No due date')); + expect( + state['calendarSources'], + contains(containsPair('id', 'calendar-b')), + ); + expect( + state['taskLists'], + contains( + allOf( + containsPair('accountId', 'account-a'), + containsPair('taskListId', 'list-b'), + ), + ), + ); + final serializedCriteria = state['criteria'] as Map; + expect(serializedCriteria, containsPair('type', 'tasks')); + expect(serializedCriteria, containsPair('date', 'custom')); + expect(serializedCriteria, containsPair('taskCompletion', 'completed')); + expect(serializedCriteria, containsPair('taskDueState', 'noDueDate')); + expect(serializedCriteria, containsPair('customStart', '2026-09-12')); + expect(serializedCriteria, containsPair('customEnd', '2026-09-15')); + expect( + serializedCriteria['taskListKeys'], + contains( + allOf( + containsPair('accountId', 'account-a'), + containsPair('taskListId', 'list-b'), + ), + ), + ); + }); + + test( + 'dispatches native criteria, source, clear, and dismiss events', + () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler( + channel, + (call) async => call.method == 'initialize' ? true : null, + ); + final service = LinuxScheduleSearchFilterService( + channel: channel, + isLinux: true, + ); + addTearDown(service.dispose); + final session = service.claimSession(); + addTearDown(session.dispose); + await session.initialize(); + final events = []; + final subscription = session.events.listen(events.add); + addTearDown(subscription.cancel); + + for (final call in const [ + MethodCall('typeChanged', 'events'), + MethodCall('dateChanged', 'tomorrow'), + MethodCall('taskCompletionChanged', 'open'), + MethodCall('taskDueStateChanged', 'overdue'), + MethodCall('personChanged', 'Taylor'), + MethodCall('locationChanged', 'Office'), + MethodCall('customStartChanged', '2026-09-12'), + MethodCall('customEndChanged', '2026-09-15'), + MethodCall('calendarSourceToggled', { + 'sourceId': 'calendar-b', + 'selected': false, + }), + MethodCall('taskListToggled', { + 'accountId': 'account-a', + 'taskListId': 'list-b', + 'selected': true, + }), + MethodCall('clearFilters'), + MethodCall('dismissSearch'), + ]) { + await service.handleNativeMethodCall(call); + } + await Future.delayed(Duration.zero); + + expect(events[0], isA()); + expect(events[1], isA()); + expect(events[2], isA()); + expect(events[3], isA()); + expect((events[4] as LinuxScheduleSearchPersonChanged).value, 'Taylor'); + expect((events[5] as LinuxScheduleSearchLocationChanged).value, 'Office'); + expect( + (events[6] as LinuxScheduleSearchCustomStartChanged).value, + DateTime(2026, 9, 12), + ); + expect( + (events[7] as LinuxScheduleSearchCustomEndChanged).value, + DateTime(2026, 9, 15), + ); + final source = events[8] as LinuxScheduleSearchCalendarSourceToggled; + expect(source.sourceId, 'calendar-b'); + expect(source.selected, isFalse); + final taskList = events[9] as LinuxScheduleSearchTaskListToggled; + expect(taskList.key.accountId, 'account-a'); + expect(taskList.key.taskListId, 'list-b'); + expect(taskList.selected, isTrue); + expect(events[10], isA()); + expect(events[11], isA()); + }, + ); + + test('only the current, available session receives native events', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler( + channel, + (call) async => call.method == 'initialize' ? true : null, + ); + final service = LinuxScheduleSearchFilterService( + channel: channel, + isLinux: true, + ); + addTearDown(service.dispose); + final stale = service.claimSession(); + final current = service.claimSession(); + final staleEvents = []; + final currentEvents = []; + stale.events.listen(staleEvents.add); + current.events.listen(currentEvents.add); + await current.initialize(); + + await service.handleNativeMethodCall( + const MethodCall('personChanged', 'Current'), + ); + await Future.delayed(Duration.zero); + expect(staleEvents, isEmpty); + expect(currentEvents, hasLength(1)); + + current.dispose(); + stale.dispose(); + await service.handleNativeMethodCall( + const MethodCall('locationChanged', 'Ignored'), + ); + await Future.delayed(Duration.zero); + expect(staleEvents, isEmpty); + expect(currentEvents, hasLength(1)); + }); + + test('an unavailable bridge cannot dispatch into its session', () async { + final service = LinuxScheduleSearchFilterService( + channel: channel, + isLinux: false, + ); + addTearDown(service.dispose); + final session = service.claimSession(); + final events = []; + session.events.listen(events.add); + await session.initialize(); + + await service.handleNativeMethodCall( + const MethodCall('personChanged', 'Ignored'), + ); + await Future.delayed(Duration.zero); + + expect(service.isAvailable, isFalse); + expect(events, isEmpty); + }); +} + +const _labels = LinuxScheduleSearchFilterLabels( + searchFilters: 'Search filters', + type: 'Type', + all: 'All', + events: 'Events', + tasks: 'Tasks', + date: 'Date', + anyDate: 'Any date', + today: 'Today', + tomorrow: 'Tomorrow', + thisWeek: 'This week', + customRange: 'Custom range', + startDate: 'Start date', + endDate: 'End date', + taskStatus: 'Task status', + open: 'Open', + completed: 'Completed', + taskDue: 'Task due', + anyDueState: 'Any due state', + overdue: 'Overdue', + noDueDate: 'No due date', + person: 'Person', + location: 'Location', + sources: 'Sources', + clearFilters: 'Clear filters', + noSources: 'No search sources selected', + close: 'Close', + cancel: 'Cancel', + ok: 'OK', +); From 1cdebc0d89493bc313fa1f67cd29033275878a83 Mon Sep 17 00:00:00 2001 From: ChenZhenIO Date: Tue, 15 Sep 2026 16:05:49 -0700 Subject: [PATCH 11/25] Refactor sidebar and filters ui --- .../presentation/schedule_search_filters.dart | 309 +++++ .../presentation/schedule_workspace.dart | 251 +--- .../linux_schedule_search_filter_service.dart | 566 -------- linux/runner/my_application.cc | 1219 +---------------- test/app/native_ui_audit_test.dart | 89 +- .../schedule_search_filters_test.dart | 332 +++++ .../schedule_search_platform_test.dart | 260 ++-- .../schedule_workspace_states_test.dart | 64 +- ...x_schedule_search_filter_service_test.dart | 272 ---- 9 files changed, 863 insertions(+), 2499 deletions(-) create mode 100644 lib/src/features/schedule/presentation/schedule_search_filters.dart delete mode 100644 lib/src/platform/linux_schedule_search_filter_service.dart create mode 100644 test/features/schedule/presentation/schedule_search_filters_test.dart delete mode 100644 test/platform/linux_schedule_search_filter_service_test.dart diff --git a/lib/src/features/schedule/presentation/schedule_search_filters.dart b/lib/src/features/schedule/presentation/schedule_search_filters.dart new file mode 100644 index 00000000..61e21680 --- /dev/null +++ b/lib/src/features/schedule/presentation/schedule_search_filters.dart @@ -0,0 +1,309 @@ +import 'package:flutter/material.dart'; +import 'package:yaru/yaru.dart'; + +import '../../../app/busymax_design.dart'; +import '../../../l10n/l10n.dart'; +import '../../../schedule/schedule_filters.dart'; +import '../../../schedule/schedule_search_criteria.dart'; +import '../../accounts/data/accounts_repository.dart'; +import '../../calendar/data/calendar_repository.dart'; +import '../../task_lists/data/task_lists_repository.dart'; +import '../../tasks/presentation/desktop_date_time_fields.dart'; +import 'schedule_search_labels.dart'; + +/// BusyMax/Yaru search controls for the existing Schedule sidebar or dialog. +/// +/// This widget emits immutable criteria only. Source switches never write the +/// persistent Calendar or Task List visibility settings. +class ScheduleSearchFilters extends StatelessWidget { + const ScheduleSearchFilters({ + super.key, + required this.value, + required this.onChanged, + required this.onClear, + required this.accounts, + required this.sources, + required this.taskLists, + this.sidebar = true, + }); + + final ScheduleSearchCriteria value; + final ValueChanged onChanged; + final VoidCallback onClear; + final List accounts; + final List sources; + final List taskLists; + + /// Whether this presentation owns the standard Schedule sidebar surface. + /// + /// The narrow dialog sets this to false because [BusyMaxDialogShell] owns + /// its surface and scrolling. + final bool sidebar; + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + final children = [ + BusyMaxGroupedList( + title: sidebar ? l10n.searchFilters : null, + filled: true, + children: [ + BusyMaxComboRow( + title: l10n.searchType, + values: ScheduleSearchType.values, + selected: value.type, + labelFor: (type) => searchTypeLabel(l10n, type), + onSelected: (type) => onChanged(value.copyWith(type: type)), + ), + BusyMaxComboRow( + title: l10n.searchDate, + values: ScheduleSearchDate.values, + selected: value.date, + labelFor: (date) => searchDateLabel(l10n, date), + onSelected: (date) => onChanged(value.copyWith(date: date)), + ), + if (value.date == ScheduleSearchDate.custom) ...[ + _dateRow( + l10n.startDate, + value.customStart ?? value.referenceDate, + start: true, + ), + _dateRow( + l10n.endDate, + value.customEnd ?? value.referenceDate, + start: false, + ), + ], + if (value.includesTasks) ...[ + BusyMaxComboRow( + title: l10n.searchTaskStatus, + values: ScheduleTaskCompletion.values, + selected: value.taskCompletion, + labelFor: (completion) => searchCompletionLabel(l10n, completion), + onSelected: (completion) => + onChanged(value.copyWith(taskCompletion: completion)), + ), + BusyMaxComboRow( + title: l10n.searchTaskDue, + values: ScheduleTaskDueState.values, + selected: value.taskDueState, + labelFor: (dueState) => searchDueLabel(l10n, dueState), + onSelected: (dueState) => + onChanged(value.copyWith(taskDueState: dueState)), + ), + ], + ], + ), + BusyMaxGroupedList( + filled: true, + children: [ + if (value.includesEvents) + _SearchFilterTextRow( + key: const ValueKey('schedule-search-person'), + label: l10n.searchPerson, + value: value.person, + icon: YaruIcons.user, + onChanged: (person) => onChanged(value.copyWith(person: person)), + ), + _SearchFilterTextRow( + key: const ValueKey('schedule-search-location'), + label: l10n.location, + value: value.location, + icon: Icons.location_on_outlined, + onChanged: (location) => + onChanged(value.copyWith(location: location)), + ), + ], + ), + ..._sourceGroups(), + if (!value.hasSources) + BusyMaxGroupedList( + title: l10n.searchSources, + filled: true, + children: [ + YaruListTile.square( + leading: const Icon(Icons.info_outline), + title: Text(l10n.searchNoSources), + ), + ], + ), + BusyMaxGroupedList( + filled: true, + children: [ + BusyMaxActionRow( + key: const ValueKey('schedule-search-clear-filters'), + title: l10n.searchClearFilters, + leading: const Icon(YaruIcons.refresh), + onTap: onClear, + ), + ], + ), + const SizedBox(height: BusyMaxSpacing.lg), + ]; + + if (!sidebar) { + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: children, + ); + } + + return BusyMaxSidebarSurface( + child: ListView( + key: const ValueKey('schedule-search-filters'), + padding: const EdgeInsetsDirectional.symmetric( + horizontal: BusyMaxSpacing.sm, + ), + children: children, + ), + ); + } + + List _sourceGroups() { + final groups = []; + for (final account in accounts) { + final rows = [ + if (value.includesEvents) + for (final source in sources.where( + (source) => source.accountId == account.id && !source.isDeleted, + )) + BusyMaxSwitchRow( + key: ValueKey(('schedule-search-source', source.id)), + title: source.summary, + value: value.sourceIds.contains(source.id), + leading: const Icon(YaruIcons.calendar), + onChanged: (selected) { + final ids = {...value.sourceIds}; + if (selected) { + ids.add(source.id); + } else { + ids.remove(source.id); + } + onChanged(value.copyWith(sourceIds: ids)); + }, + ), + if (value.includesTasks) + for (final list in taskLists.where( + (list) => list.accountId == account.id && !list.pendingDelete, + )) + BusyMaxSwitchRow( + key: ValueKey(( + 'schedule-search-task-list', + list.accountId, + list.id, + )), + title: list.title, + value: value.taskListKeys.contains( + ScheduleTaskListKey( + accountId: list.accountId, + taskListId: list.id, + ), + ), + leading: const Icon(YaruIcons.task_list), + onChanged: (selected) { + final keys = {...value.taskListKeys}; + final key = ScheduleTaskListKey( + accountId: list.accountId, + taskListId: list.id, + ); + if (selected) { + keys.add(key); + } else { + keys.remove(key); + } + onChanged(value.copyWith(taskListKeys: keys)); + }, + ), + ]; + if (rows.isEmpty) { + continue; + } + groups.add( + BusyMaxGroupedList( + title: account.displayLabel, + filled: true, + children: rows, + ), + ); + } + + return groups; + } + + Widget _dateRow(String label, DateTime date, {required bool start}) { + return DesktopDateValueRow( + label: label, + date: encodeDateOnly(date), + useNativePicker: true, + onChanged: (date) { + final picked = DateTime.parse(date); + final from = start ? picked : value.customStart ?? value.referenceDate; + final to = start ? value.customEnd ?? value.referenceDate : picked; + onChanged( + value.copyWith( + customStart: from.isAfter(to) ? picked : from, + customEnd: to.isBefore(from) ? picked : to, + ), + ); + }, + ); + } +} + +class _SearchFilterTextRow extends StatefulWidget { + const _SearchFilterTextRow({ + super.key, + required this.label, + required this.value, + required this.icon, + required this.onChanged, + }); + + final String label; + final String value; + final IconData icon; + final ValueChanged onChanged; + + @override + State<_SearchFilterTextRow> createState() => _SearchFilterTextRowState(); +} + +class _SearchFilterTextRowState extends State<_SearchFilterTextRow> { + late final TextEditingController _controller = TextEditingController( + text: widget.value, + ); + + @override + void didUpdateWidget(covariant _SearchFilterTextRow oldWidget) { + super.didUpdateWidget(oldWidget); + if (_controller.text == widget.value) { + return; + } + _controller.value = TextEditingValue( + text: widget.value, + selection: TextSelection.collapsed(offset: widget.value.length), + ); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return YaruListTile.square( + leading: Icon(widget.icon), + title: TextField( + controller: _controller, + decoration: busyMaxGroupedTextFieldDecoration( + context, + labelText: widget.label, + ), + onChanged: widget.onChanged, + ), + ); + } +} diff --git a/lib/src/features/schedule/presentation/schedule_workspace.dart b/lib/src/features/schedule/presentation/schedule_workspace.dart index 393f081a..abe2cd5b 100644 --- a/lib/src/features/schedule/presentation/schedule_workspace.dart +++ b/lib/src/features/schedule/presentation/schedule_workspace.dart @@ -27,7 +27,6 @@ import '../../../l10n/l10n.dart'; import '../../../l10n/localized_formatters.dart'; import '../../../platform/linux_header_bar_service.dart'; import '../../../platform/linux_header_bar_provider.dart'; -import '../../../platform/linux_schedule_search_filter_service.dart'; import '../../../schedule/schedule_commands.dart'; import '../../../schedule/schedule_filters.dart'; import '../../../schedule/schedule_item.dart'; @@ -61,6 +60,7 @@ import 'schedule_item_exporter.dart'; import 'schedule_item_selection.dart'; import 'schedule_month_view.dart'; import 'schedule_sidebar.dart'; +import 'schedule_search_filters.dart'; import '../../../schedule/schedule_search_criteria.dart'; import 'schedule_toolbar.dart'; import 'schedule_year_view.dart'; @@ -179,10 +179,8 @@ class _ScheduleWorkspaceState extends ConsumerState { late ScheduleScope _scope; _TaskDetailsTarget? _taskDetailsTarget; late final LinuxHeaderBarSession _headerBarSession; - late final LinuxScheduleSearchFilterSession _searchFilterSession; StreamSubscription? _headerBarActions; StreamSubscription? _headerBarSearchEvents; - StreamSubscription? _searchFilterEvents; var _headerBarReady = false; var _nativeHeaderBarAvailable = false; var _sidebarCollapsed = false; @@ -191,6 +189,8 @@ class _ScheduleWorkspaceState extends ConsumerState { bool? _sidebarBeforeSearch; ScheduleSearchCriteria? _searchCriteria; ScheduleSearchCriteria? _initialSearchCriteria; + List _searchSources = const []; + List _searchTaskLists = const []; ScheduleSourceVisibility? _searchVisibility; final _searchController = TextEditingController(); final _workspaceFocusNode = FocusNode(); @@ -235,13 +235,6 @@ class _ScheduleWorkspaceState extends ConsumerState { _headerBarSearchEvents = _headerBarSession.searchEvents.listen( _handleHeaderBarSearchEvent, ); - _searchFilterSession = ref - .read(linuxScheduleSearchFilterServiceProvider) - .claimSession(); - _searchFilterEvents = _searchFilterSession.events.listen( - _handleNativeSearchFilterEvent, - ); - unawaited(_searchFilterSession.initialize()); unawaited(_initializeHeaderBar()); _scheduleInitialTaskWatch(); } @@ -254,7 +247,6 @@ class _ScheduleWorkspaceState extends ConsumerState { unawaited(createChoiceMenuSession.dismiss()); } _headerBarSession.dispose(); - _searchFilterSession.dispose(); if (_taskDetailsTarget != null) { unawaited( releaseBusyMaxModalBarrier(ref.read(linuxHeaderBarServiceProvider)), @@ -262,7 +254,6 @@ class _ScheduleWorkspaceState extends ConsumerState { } unawaited(_headerBarActions?.cancel()); unawaited(_headerBarSearchEvents?.cancel()); - unawaited(_searchFilterEvents?.cancel()); unawaited(_initialTaskTargetSubscription?.cancel()); _searchController.dispose(); _workspaceFocusNode.dispose(); @@ -287,7 +278,6 @@ class _ScheduleWorkspaceState extends ConsumerState { @override Widget build(BuildContext context) { - final routeIsCurrent = ModalRoute.of(context)?.isCurrent ?? true; final settings = ref.watch(appSettingsControllerProvider); _syncModeFromSettings(settings.scheduleViewMode); final range = _range(context); @@ -331,6 +321,8 @@ class _ScheduleWorkspaceState extends ConsumerState { taskLists: taskLists, settings: settings, ); + _searchSources = sources; + _searchTaskLists = taskLists; _searchVisibility = visibility; if (_searchActive && _searchCriteria == null && @@ -452,9 +444,9 @@ class _ScheduleWorkspaceState extends ConsumerState { ); Widget buildSidebar() { if (searchActive && _searchCriteria != null) { - return const SizedBox( - key: ValueKey('linux-native-search-filter-spacer'), + return SizedBox( width: BusyMaxSizes.sidebarWidth, + child: _searchFilterPanel(), ); } return SizedBox( @@ -509,7 +501,7 @@ class _ScheduleWorkspaceState extends ConsumerState { YaruIconButton( tooltip: context.l10n.searchFiltersAction, icon: const Icon(Icons.filter_list), - onPressed: _showNativeSearchFilters, + onPressed: _showSearchFilters, ), YaruIconButton( tooltip: context.l10n.close, @@ -687,14 +679,6 @@ class _ScheduleWorkspaceState extends ConsumerState { canCreateTask: canCreateTask, showSidebar: showSidebar, ); - _updateNativeSearchFilterState( - context, - accounts: accounts, - sources: sources, - taskLists: taskLists, - showSidebar: showSidebar, - routeIsCurrent: routeIsCurrent, - ); final body = !showSidebar || _sidebarCollapsed ? main : Row( @@ -819,161 +803,6 @@ class _ScheduleWorkspaceState extends ConsumerState { }); } - void _updateNativeSearchFilterState( - BuildContext context, { - required List accounts, - required List sources, - required List taskLists, - required bool showSidebar, - required bool routeIsCurrent, - }) { - final l = context.l10n; - final material = MaterialLocalizations.of(context); - final criteria = _searchCriteria; - final state = LinuxScheduleSearchFilterState( - active: routeIsCurrent && _searchActive && criteria != null, - sidebarVisible: - routeIsCurrent && - _searchActive && - criteria != null && - showSidebar && - !_sidebarCollapsed, - sidebarWidth: BusyMaxSizes.sidebarWidth, - criteria: criteria, - labels: LinuxScheduleSearchFilterLabels( - searchFilters: l.searchFilters, - type: l.searchType, - all: l.all, - events: l.calendarEvents, - tasks: l.tasks, - date: l.searchDate, - anyDate: l.searchAnyDate, - today: l.today, - tomorrow: l.tomorrow, - thisWeek: l.searchThisWeek, - customRange: l.searchCustomRange, - startDate: l.startDate, - endDate: l.endDate, - taskStatus: l.searchTaskStatus, - open: l.openStatus, - completed: l.completed, - taskDue: l.searchTaskDue, - anyDueState: l.searchAnyDueState, - overdue: l.overdue, - noDueDate: l.searchNoDueDate, - person: l.searchPerson, - location: l.location, - sources: l.searchSources, - clearFilters: l.searchClearFilters, - noSources: l.searchNoSources, - close: l.close, - cancel: material.cancelButtonLabel, - ok: material.okButtonLabel, - ), - accounts: [ - for (final account in accounts) - LinuxScheduleSearchFilterAccount( - id: account.id, - label: account.displayLabel, - ), - ], - calendarSources: [ - for (final source in sources) - if (!source.isDeleted) - LinuxScheduleSearchFilterCalendarSource( - id: source.id, - accountId: source.accountId, - title: source.summary, - selected: criteria?.sourceIds.contains(source.id) ?? false, - ), - ], - taskLists: [ - for (final taskList in taskLists) - if (!taskList.pendingDelete) - LinuxScheduleSearchFilterTaskList( - accountId: taskList.accountId, - taskListId: taskList.id, - title: taskList.title, - selected: - criteria?.taskListKeys.contains( - ScheduleTaskListKey( - accountId: taskList.accountId, - taskListId: taskList.id, - ), - ) ?? - false, - ), - ], - ); - WidgetsBinding.instance.addPostFrameCallback((_) { - if (!mounted) return; - unawaited(_searchFilterSession.updateState(state)); - }); - } - - void _handleNativeSearchFilterEvent(LinuxScheduleSearchFilterEvent event) { - if (!_searchFilterSession.isCurrent || - !_searchActive || - !(ModalRoute.of(context)?.isCurrent ?? true)) { - return; - } - final criteria = _searchCriteria; - if (criteria == null) return; - switch (event) { - case LinuxScheduleSearchTypeChanged(:final value): - setState(() => _searchCriteria = criteria.copyWith(type: value)); - case LinuxScheduleSearchDateChanged(:final value): - setState(() => _searchCriteria = criteria.copyWith(date: value)); - case LinuxScheduleSearchTaskCompletionChanged(:final value): - setState( - () => _searchCriteria = criteria.copyWith(taskCompletion: value), - ); - case LinuxScheduleSearchTaskDueStateChanged(:final value): - setState( - () => _searchCriteria = criteria.copyWith(taskDueState: value), - ); - case LinuxScheduleSearchPersonChanged(:final value): - setState(() => _searchCriteria = criteria.copyWith(person: value)); - case LinuxScheduleSearchLocationChanged(:final value): - setState(() => _searchCriteria = criteria.copyWith(location: value)); - case LinuxScheduleSearchCustomStartChanged(:final value): - final end = criteria.customEnd ?? criteria.referenceDate; - setState( - () => _searchCriteria = criteria.copyWith( - customStart: value, - customEnd: end.isBefore(value) ? value : end, - ), - ); - case LinuxScheduleSearchCustomEndChanged(:final value): - final start = criteria.customStart ?? criteria.referenceDate; - setState( - () => _searchCriteria = criteria.copyWith( - customStart: start.isAfter(value) ? value : start, - customEnd: value, - ), - ); - case LinuxScheduleSearchCalendarSourceToggled( - :final sourceId, - :final selected, - ): - final sourceIds = {...criteria.sourceIds}; - selected ? sourceIds.add(sourceId) : sourceIds.remove(sourceId); - setState( - () => _searchCriteria = criteria.copyWith(sourceIds: sourceIds), - ); - case LinuxScheduleSearchTaskListToggled(:final key, :final selected): - final taskListKeys = {...criteria.taskListKeys}; - selected ? taskListKeys.add(key) : taskListKeys.remove(key); - setState( - () => _searchCriteria = criteria.copyWith(taskListKeys: taskListKeys), - ); - case LinuxScheduleSearchClearRequested(): - setState(() => _searchCriteria = _initialSearchCriteria); - case LinuxScheduleSearchDismissRequested(): - _closeSearch(); - } - } - void _handleFallbackToolbarMenu(ScheduleToolbarMenuAction action) { switch (action) { case ScheduleToolbarMenuAction.refresh: @@ -1060,7 +889,7 @@ class _ScheduleWorkspaceState extends ConsumerState { return; case BusyMaxHeaderBarAction.sidebarToggle: if (!_latestCanShowSidebar) { - if (_searchActive) unawaited(_showNativeSearchFilters()); + if (_searchActive) unawaited(_showSearchFilters()); return; } setState(() => _sidebarCollapsed = !_sidebarCollapsed); @@ -1208,9 +1037,67 @@ class _ScheduleWorkspaceState extends ConsumerState { }); } - Future _showNativeSearchFilters() async { + void _focusSearchFilters() { + if (_nativeHeaderBarAvailable) unawaited(_headerBarSession.focusContent()); + } + + Widget _searchFilterPanel({VoidCallback? refresh, bool sidebar = true}) => + Listener( + onPointerDown: (_) => _focusSearchFilters(), + child: Focus( + onFocusChange: (focused) { + if (focused) _focusSearchFilters(); + }, + child: ScheduleSearchFilters( + value: _searchCriteria!, + accounts: _latestAccounts, + sources: _searchSources, + taskLists: _searchTaskLists, + sidebar: sidebar, + onChanged: (value) { + setState(() => _searchCriteria = value); + refresh?.call(); + }, + onClear: () { + setState(() => _searchCriteria = _initialSearchCriteria); + refresh?.call(); + }, + ), + ), + ); + + Future _showSearchFilters() async { if (_searchCriteria == null) return; - await _searchFilterSession.showModal(); + final dismissWhenWide = !BusyMaxLayoutRules.showSidebar( + MediaQuery.sizeOf(context).width, + ); + var dismissScheduled = false; + await showBusyMaxModalDialog( + context, + headerBarService: ref.read(linuxHeaderBarServiceProvider), + builder: (dialogContext) => StatefulBuilder( + builder: (context, update) { + if (dismissWhenWide && + BusyMaxLayoutRules.showSidebar( + MediaQuery.sizeOf(context).width, + )) { + if (!dismissScheduled) { + dismissScheduled = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (context.mounted) Navigator.of(context).maybePop(); + }); + } + return const SizedBox.shrink(); + } + return BusyMaxDialogShell( + title: dialogContext.l10n.searchFilters, + children: [ + _searchFilterPanel(sidebar: false, refresh: () => update(() {})), + ], + ); + }, + ), + ); } Future<_ScheduleItemsResult> _scheduleItems({ diff --git a/lib/src/platform/linux_schedule_search_filter_service.dart b/lib/src/platform/linux_schedule_search_filter_service.dart deleted file mode 100644 index 5eb4ee46..00000000 --- a/lib/src/platform/linux_schedule_search_filter_service.dart +++ /dev/null @@ -1,566 +0,0 @@ -import 'dart:async'; -import 'dart:io'; - -import 'package:collection/collection.dart'; -import 'package:flutter/foundation.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -import '../schedule/schedule_filters.dart'; -import '../schedule/schedule_search_criteria.dart'; - -@visibleForTesting -const linuxScheduleSearchFilterChannelName = - 'busymax/native_schedule_search_filters'; - -@immutable -final class LinuxScheduleSearchFilterLabels { - const LinuxScheduleSearchFilterLabels({ - required this.searchFilters, - required this.type, - required this.all, - required this.events, - required this.tasks, - required this.date, - required this.anyDate, - required this.today, - required this.tomorrow, - required this.thisWeek, - required this.customRange, - required this.startDate, - required this.endDate, - required this.taskStatus, - required this.open, - required this.completed, - required this.taskDue, - required this.anyDueState, - required this.overdue, - required this.noDueDate, - required this.person, - required this.location, - required this.sources, - required this.clearFilters, - required this.noSources, - required this.close, - required this.cancel, - required this.ok, - }); - - final String searchFilters; - final String type; - final String all; - final String events; - final String tasks; - final String date; - final String anyDate; - final String today; - final String tomorrow; - final String thisWeek; - final String customRange; - final String startDate; - final String endDate; - final String taskStatus; - final String open; - final String completed; - final String taskDue; - final String anyDueState; - final String overdue; - final String noDueDate; - final String person; - final String location; - final String sources; - final String clearFilters; - final String noSources; - final String close; - final String cancel; - final String ok; - - Map toJson() => { - 'searchFilters': searchFilters, - 'type': type, - 'all': all, - 'events': events, - 'tasks': tasks, - 'date': date, - 'anyDate': anyDate, - 'today': today, - 'tomorrow': tomorrow, - 'thisWeek': thisWeek, - 'customRange': customRange, - 'startDate': startDate, - 'endDate': endDate, - 'taskStatus': taskStatus, - 'open': open, - 'completed': completed, - 'taskDue': taskDue, - 'anyDueState': anyDueState, - 'overdue': overdue, - 'noDueDate': noDueDate, - 'person': person, - 'location': location, - 'sources': sources, - 'clearFilters': clearFilters, - 'noSources': noSources, - 'close': close, - 'cancel': cancel, - 'ok': ok, - }; -} - -@immutable -final class LinuxScheduleSearchFilterAccount { - const LinuxScheduleSearchFilterAccount({ - required this.id, - required this.label, - }); - - final String id; - final String label; - - Map toJson() => {'id': id, 'label': label}; -} - -@immutable -final class LinuxScheduleSearchFilterCalendarSource { - const LinuxScheduleSearchFilterCalendarSource({ - required this.id, - required this.accountId, - required this.title, - required this.selected, - }); - - final String id; - final String accountId; - final String title; - final bool selected; - - Map toJson() => { - 'id': id, - 'accountId': accountId, - 'title': title, - 'selected': selected, - }; -} - -@immutable -final class LinuxScheduleSearchFilterTaskList { - const LinuxScheduleSearchFilterTaskList({ - required this.accountId, - required this.taskListId, - required this.title, - required this.selected, - }); - - final String accountId; - final String taskListId; - final String title; - final bool selected; - - Map toJson() => { - 'accountId': accountId, - 'taskListId': taskListId, - 'title': title, - 'selected': selected, - }; -} - -@immutable -final class LinuxScheduleSearchFilterState { - const LinuxScheduleSearchFilterState({ - required this.active, - required this.sidebarVisible, - required this.sidebarWidth, - required this.criteria, - required this.labels, - required this.accounts, - required this.calendarSources, - required this.taskLists, - }); - - static const int schemaVersion = 1; - - final bool active; - final bool sidebarVisible; - final double sidebarWidth; - final ScheduleSearchCriteria? criteria; - final LinuxScheduleSearchFilterLabels labels; - final List accounts; - final List calendarSources; - final List taskLists; - - Map toJson() => { - 'schemaVersion': schemaVersion, - 'active': active, - 'sidebarVisible': sidebarVisible, - 'sidebarWidth': sidebarWidth, - 'criteria': criteria == null ? null : _criteriaToJson(criteria!), - 'labels': labels.toJson(), - 'accounts': [for (final account in accounts) account.toJson()], - 'calendarSources': [for (final source in calendarSources) source.toJson()], - 'taskLists': [for (final taskList in taskLists) taskList.toJson()], - }; - - static Map _criteriaToJson( - ScheduleSearchCriteria criteria, - ) => { - 'type': criteria.type.name, - 'date': criteria.date.name, - 'taskCompletion': criteria.taskCompletion.name, - 'taskDueState': criteria.taskDueState.name, - 'person': criteria.person, - 'location': criteria.location, - 'customStart': _encodeDate(criteria.customStart), - 'customEnd': _encodeDate(criteria.customEnd), - 'referenceDate': _encodeDate(criteria.referenceDate), - 'firstWeekday': criteria.firstWeekday, - 'sourceIds': criteria.sourceIds.toList()..sort(), - 'taskListKeys': - [ - for (final key in criteria.taskListKeys) - { - 'accountId': key.accountId, - 'taskListId': key.taskListId, - }, - ]..sort((a, b) { - final account = a['accountId']!.compareTo(b['accountId']!); - return account != 0 - ? account - : a['taskListId']!.compareTo(b['taskListId']!); - }), - }; -} - -String? _encodeDate(DateTime? value) { - if (value == null) return null; - String two(int part) => part.toString().padLeft(2, '0'); - return '${value.year.toString().padLeft(4, '0')}-${two(value.month)}-${two(value.day)}'; -} - -sealed class LinuxScheduleSearchFilterEvent { - const LinuxScheduleSearchFilterEvent(); -} - -final class LinuxScheduleSearchTypeChanged - extends LinuxScheduleSearchFilterEvent { - const LinuxScheduleSearchTypeChanged(this.value); - final ScheduleSearchType value; -} - -final class LinuxScheduleSearchDateChanged - extends LinuxScheduleSearchFilterEvent { - const LinuxScheduleSearchDateChanged(this.value); - final ScheduleSearchDate value; -} - -final class LinuxScheduleSearchTaskCompletionChanged - extends LinuxScheduleSearchFilterEvent { - const LinuxScheduleSearchTaskCompletionChanged(this.value); - final ScheduleTaskCompletion value; -} - -final class LinuxScheduleSearchTaskDueStateChanged - extends LinuxScheduleSearchFilterEvent { - const LinuxScheduleSearchTaskDueStateChanged(this.value); - final ScheduleTaskDueState value; -} - -final class LinuxScheduleSearchPersonChanged - extends LinuxScheduleSearchFilterEvent { - const LinuxScheduleSearchPersonChanged(this.value); - final String value; -} - -final class LinuxScheduleSearchLocationChanged - extends LinuxScheduleSearchFilterEvent { - const LinuxScheduleSearchLocationChanged(this.value); - final String value; -} - -final class LinuxScheduleSearchCustomStartChanged - extends LinuxScheduleSearchFilterEvent { - const LinuxScheduleSearchCustomStartChanged(this.value); - final DateTime value; -} - -final class LinuxScheduleSearchCustomEndChanged - extends LinuxScheduleSearchFilterEvent { - const LinuxScheduleSearchCustomEndChanged(this.value); - final DateTime value; -} - -final class LinuxScheduleSearchCalendarSourceToggled - extends LinuxScheduleSearchFilterEvent { - const LinuxScheduleSearchCalendarSourceToggled({ - required this.sourceId, - required this.selected, - }); - final String sourceId; - final bool selected; -} - -final class LinuxScheduleSearchTaskListToggled - extends LinuxScheduleSearchFilterEvent { - const LinuxScheduleSearchTaskListToggled({ - required this.key, - required this.selected, - }); - final ScheduleTaskListKey key; - final bool selected; -} - -final class LinuxScheduleSearchClearRequested - extends LinuxScheduleSearchFilterEvent { - const LinuxScheduleSearchClearRequested(); -} - -final class LinuxScheduleSearchDismissRequested - extends LinuxScheduleSearchFilterEvent { - const LinuxScheduleSearchDismissRequested(); -} - -class LinuxScheduleSearchFilterService { - LinuxScheduleSearchFilterService({ - MethodChannel channel = const MethodChannel( - linuxScheduleSearchFilterChannelName, - ), - bool? isLinux, - }) : _channel = channel, - _isLinux = isLinux ?? Platform.isLinux; - - final MethodChannel _channel; - final bool _isLinux; - final List _sessions = []; - Future? _initialization; - bool _available = false; - bool _disposed = false; - Map? _appliedState; - - bool get isAvailable => _available; - - LinuxScheduleSearchFilterSession claimSession() { - if (_disposed) { - throw StateError('Cannot claim a session from a disposed service.'); - } - final session = LinuxScheduleSearchFilterSession._(this); - _sessions.add(session); - return session; - } - - Future initialize() => _initialization ??= _initialize(); - - Future _initialize() async { - if (_disposed) return; - _channel.setMethodCallHandler(handleNativeMethodCall); - if (!_isLinux) return; - try { - _available = await _channel.invokeMethod('initialize') ?? false; - } on MissingPluginException { - _available = false; - } on PlatformException { - _available = false; - } - } - - LinuxScheduleSearchFilterSession? get _activeSession => - _sessions.isEmpty ? null : _sessions.last; - - bool _isCurrent(LinuxScheduleSearchFilterSession session) => - identical(session, _activeSession); - - Future _applyState( - LinuxScheduleSearchFilterState state, { - bool force = false, - }) async { - final serialized = state.toJson(); - if (!force && - const DeepCollectionEquality().equals(_appliedState, serialized)) { - return; - } - _appliedState = serialized; - await _invokeVoid('setState', serialized); - } - - Future _showModal(LinuxScheduleSearchFilterSession session) async { - if (!_available || !_isCurrent(session)) return false; - try { - return await _channel.invokeMethod('showModal') ?? false; - } on MissingPluginException { - _available = false; - } on PlatformException { - _available = false; - } - return false; - } - - Future _hideModal(LinuxScheduleSearchFilterSession session) async { - if (!_available || !_isCurrent(session)) return; - await _invokeVoid('hideModal'); - } - - Future _invokeVoid(String method, [Object? arguments]) async { - if (_disposed || !_available) return; - try { - await _channel.invokeMethod(method, arguments); - } on MissingPluginException { - _available = false; - } on PlatformException { - _available = false; - } - } - - @visibleForTesting - Future handleNativeMethodCall(MethodCall call) async { - if (_disposed || !_available) return; - final event = _eventFor(call); - if (event != null) _activeSession?._dispatch(event); - } - - LinuxScheduleSearchFilterEvent? _eventFor(MethodCall call) { - try { - return switch ((call.method, call.arguments)) { - ('typeChanged', final String value) => LinuxScheduleSearchTypeChanged( - ScheduleSearchType.values.byName(value), - ), - ('dateChanged', final String value) => LinuxScheduleSearchDateChanged( - ScheduleSearchDate.values.byName(value), - ), - ('taskCompletionChanged', final String value) => - LinuxScheduleSearchTaskCompletionChanged( - ScheduleTaskCompletion.values.byName(value), - ), - ('taskDueStateChanged', final String value) => - LinuxScheduleSearchTaskDueStateChanged( - ScheduleTaskDueState.values.byName(value), - ), - ('personChanged', final String value) => - LinuxScheduleSearchPersonChanged(value), - ('locationChanged', final String value) => - LinuxScheduleSearchLocationChanged(value), - ('customStartChanged', final String value) => - LinuxScheduleSearchCustomStartChanged(DateTime.parse(value)), - ('customEndChanged', final String value) => - LinuxScheduleSearchCustomEndChanged(DateTime.parse(value)), - ('calendarSourceToggled', final Map value) - when value['sourceId'] is String && value['selected'] is bool => - LinuxScheduleSearchCalendarSourceToggled( - sourceId: value['sourceId']! as String, - selected: value['selected']! as bool, - ), - ('taskListToggled', final Map value) - when value['accountId'] is String && - value['taskListId'] is String && - value['selected'] is bool => - LinuxScheduleSearchTaskListToggled( - key: ScheduleTaskListKey( - accountId: value['accountId']! as String, - taskListId: value['taskListId']! as String, - ), - selected: value['selected']! as bool, - ), - ('clearFilters', _) => const LinuxScheduleSearchClearRequested(), - ('dismissSearch', _) => const LinuxScheduleSearchDismissRequested(), - _ => null, - }; - } on ArgumentError { - return null; - } on FormatException { - return null; - } - } - - void _release(LinuxScheduleSearchFilterSession session) { - final wasCurrent = _isCurrent(session); - _sessions.remove(session); - if (!wasCurrent) return; - final current = _activeSession; - if (current != null) { - unawaited(current._restore()); - } else { - _appliedState = null; - unawaited(_invokeVoid('hide')); - } - } - - void dispose() { - if (_disposed) return; - if (_available) unawaited(_invokeVoid('hide')); - _disposed = true; - _available = false; - for (final session in _sessions.toList()) { - session._disposeFromService(); - } - _sessions.clear(); - _channel.setMethodCallHandler(null); - } -} - -class LinuxScheduleSearchFilterSession { - LinuxScheduleSearchFilterSession._(this._service); - - final LinuxScheduleSearchFilterService _service; - final StreamController _events = - StreamController.broadcast(); - LinuxScheduleSearchFilterState? _state; - int _revision = 0; - bool _disposed = false; - - bool get isCurrent => !_disposed && _service._isCurrent(this); - bool get isAvailable => !_disposed && _service.isAvailable; - Stream get events => _events.stream; - - Future initialize() => _service.initialize(); - - Future updateState( - LinuxScheduleSearchFilterState state, { - bool force = false, - }) async { - if (_disposed) return; - _state = state; - final revision = ++_revision; - await initialize(); - if (!isCurrent || revision != _revision) return; - await _service._applyState(state, force: force); - } - - Future showModal() async { - if (_disposed) return false; - await initialize(); - if (!isCurrent) return false; - final state = _state; - if (state != null) await _service._applyState(state, force: true); - return _service._showModal(this); - } - - Future hideModal() => _service._hideModal(this); - - Future _restore() async { - await initialize(); - final state = _state; - if (!isCurrent || state == null) return; - await _service._applyState(state, force: true); - } - - void _dispatch(LinuxScheduleSearchFilterEvent event) { - if (isCurrent && !_events.isClosed) _events.add(event); - } - - void dispose() { - if (_disposed) return; - _disposed = true; - _service._release(this); - unawaited(_events.close()); - } - - void _disposeFromService() { - if (_disposed) return; - _disposed = true; - unawaited(_events.close()); - } -} - -final linuxScheduleSearchFilterServiceProvider = - Provider((ref) { - final service = LinuxScheduleSearchFilterService(); - ref.onDispose(service.dispose); - return service; - }); diff --git a/linux/runner/my_application.cc b/linux/runner/my_application.cc index cd3578e5..a226ea8a 100644 --- a/linux/runner/my_application.cc +++ b/linux/runner/my_application.cc @@ -10,7 +10,6 @@ #include #include #include -#include #include "flutter/generated_plugin_registrant.h" constexpr char kApplicationDisplayName[] = "BusyMax"; @@ -18,8 +17,6 @@ constexpr char kNativeDateTimePickerChannel[] = "busymax/native_date_time_picker"; constexpr char kNativeDialogChannel[] = "busymax/native_dialogs"; constexpr char kNativeMenuChannel[] = "busymax/native_menus"; -constexpr char kNativeScheduleSearchFiltersChannel[] = - "busymax/native_schedule_search_filters"; constexpr char kWindowChannel[] = "io.busystack.busymax/window"; constexpr char kHeaderBarChannel[] = "io.busystack.busymax/headerbar"; constexpr char kGtkSettingsChannel[] = "io.busystack.busymax/gtk_settings"; @@ -96,11 +93,6 @@ constexpr char kNativeTimeZoneRowStyleClass[] = "busymax-time-zone-row"; constexpr gint kNativeTimeZoneDialogWidth = 520; constexpr gint kNativeTimeZoneDialogContentHeight = 420; constexpr size_t kNativeTimeZoneResultLimit = 250; -constexpr gint64 kNativeScheduleSearchFilterSchemaVersion = 1; -constexpr gint kNativeScheduleSearchFilterDialogWidth = 420; -constexpr gint kNativeScheduleSearchFilterDialogHeight = 680; - -struct NativeScheduleFilterControls; struct _MyApplication { GtkApplication parent_instance; @@ -109,7 +101,6 @@ struct _MyApplication { FlMethodChannel* native_date_time_picker_channel; FlMethodChannel* native_dialog_channel; FlMethodChannel* native_menu_channel; - FlMethodChannel* native_schedule_search_filters_channel; FlMethodChannel* window_channel; FlMethodChannel* header_bar_channel; FlMethodChannel* gtk_settings_channel; @@ -151,7 +142,6 @@ struct _MyApplication { GtkWindow* main_window; GtkWindow* header_focus_transient_window; GtkWidget* flutter_view; - GtkWidget* content_overlay; GtkWidget* titlebar_handle; GtkWidget* titlebar_overlay; GtkWidget* titlebar_modal_barrier; @@ -221,10 +211,6 @@ struct _MyApplication { gboolean header_back_visible; gboolean header_onboarding_controls_visible; gint header_onboarding_content_width; - NativeScheduleFilterControls* schedule_filter_sidebar; - NativeScheduleFilterControls* schedule_filter_modal; - FlValue* schedule_filter_state; - gboolean suppress_schedule_filter_events; }; struct PendingExternalOpen { @@ -482,11 +468,13 @@ static void style_native_dialog(GtkWidget* dialog) { } } -static gchar* run_native_date_picker(GtkWindow* parent, - const gchar* title, - const gchar* initial_date, - const gchar* cancel_label, - const gchar* ok_label) { +static void handle_pick_date(FlMethodCall* method_call, + FlValue* args, + GtkWindow* parent) { + const gchar* title = fl_lookup_string_arg(args, "title"); + const gchar* initial_date = fl_lookup_string_arg(args, "initialDate"); + const gchar* cancel_label = fl_lookup_string_arg(args, "cancelLabel"); + const gchar* ok_label = fl_lookup_string_arg(args, "okLabel"); GtkWidget* dialog = gtk_dialog_new_with_buttons( title != nullptr ? title : "Date", parent, static_cast(GTK_DIALOG_MODAL | @@ -517,25 +505,16 @@ static gchar* run_native_date_picker(GtkWindow* parent, gtk_widget_show_all(dialog); const gint response = gtk_dialog_run(GTK_DIALOG(dialog)); - gchar* result = nullptr; if (response == GTK_RESPONSE_OK) { gtk_calendar_get_date(GTK_CALENDAR(calendar), &year, &month, &day); - result = g_strdup_printf("%04u-%02u-%02u", year, month + 1, day); + g_autofree gchar* result = + g_strdup_printf("%04u-%02u-%02u", year, month + 1, day); + respond_string(method_call, result); + } else { + respond_string(method_call, nullptr); } gtk_widget_destroy(dialog); - return result; -} - -static void handle_pick_date(FlMethodCall* method_call, - FlValue* args, - GtkWindow* parent) { - g_autofree gchar* result = run_native_date_picker( - parent, fl_lookup_string_arg(args, "title"), - fl_lookup_string_arg(args, "initialDate"), - fl_lookup_string_arg(args, "cancelLabel"), - fl_lookup_string_arg(args, "okLabel")); - respond_string(method_call, result); } static void handle_pick_time(FlMethodCall* method_call, @@ -1977,1118 +1956,6 @@ static void respond_success(FlMethodCall* method_call) { fl_method_call_respond_success(method_call, result, nullptr); } -struct NativeScheduleFilterSourceRow { - GtkWidget* group; - GtkWidget* row; - GtkWidget* check_button; - gboolean is_task_list; - gchar* source_id; - gchar* account_id; - gchar* task_list_id; -}; - -struct NativeScheduleFilterControls { - MyApplication* application; - GtkWidget* window; - GtkWidget* root; - GtkWidget* scrolled_window; - GtkWidget* content; - GtkWidget* title_label; - GtkWidget* general_group; - GtkWidget* type_row; - GtkWidget* type_combo; - GtkWidget* date_row; - GtkWidget* date_combo; - GtkWidget* start_date_row; - GtkWidget* start_date_button; - GtkWidget* end_date_row; - GtkWidget* end_date_button; - GtkWidget* task_completion_row; - GtkWidget* task_completion_combo; - GtkWidget* task_due_row; - GtkWidget* task_due_combo; - GtkWidget* person_row; - GtkWidget* person_entry; - GtkWidget* location_row; - GtkWidget* location_entry; - GtkWidget* sources_label; - GtkWidget* sources_box; - GtkWidget* no_sources_label; - GtkWidget* clear_button; - GPtrArray* source_rows; - gchar* topology_signature; - gchar* labels_signature; - gboolean modal; -}; - -static void native_schedule_filter_source_row_free(gpointer data) { - auto* row = static_cast(data); - if (row == nullptr) return; - g_free(row->source_id); - g_free(row->account_id); - g_free(row->task_list_id); - g_free(row); -} - -static void native_schedule_filter_controls_free( - NativeScheduleFilterControls* controls) { - if (controls == nullptr) return; - g_clear_pointer(&controls->source_rows, g_ptr_array_unref); - g_clear_pointer(&controls->topology_signature, g_free); - g_clear_pointer(&controls->labels_signature, g_free); - g_free(controls); -} - -static FlValue* native_schedule_filter_criteria(MyApplication* self) { - return self->schedule_filter_state == nullptr - ? nullptr - : fl_lookup_map_arg(self->schedule_filter_state, "criteria"); -} - -static FlValue* fl_lookup_list_arg(FlValue* args, const gchar* key) { - if (args == nullptr || fl_value_get_type(args) != FL_VALUE_TYPE_MAP) { - return nullptr; - } - FlValue* value = fl_value_lookup_string(args, key); - return value != nullptr && fl_value_get_type(value) == FL_VALUE_TYPE_LIST - ? value - : nullptr; -} - -static gboolean native_schedule_filter_has_string(FlValue* map, - const gchar* key) { - return fl_lookup_string_arg(map, key) != nullptr; -} - -static gboolean native_schedule_filter_valid_enum(const gchar* value, - const gchar* const* values, - gsize count) { - if (value == nullptr) return FALSE; - for (gsize index = 0; index < count; index++) { - if (g_strcmp0(value, values[index]) == 0) return TRUE; - } - return FALSE; -} - -static gboolean native_schedule_filter_valid_optional_date(FlValue* map, - const gchar* key) { - FlValue* value = fl_value_lookup_string(map, key); - if (value == nullptr || fl_value_get_type(value) == FL_VALUE_TYPE_NULL) { - return TRUE; - } - if (fl_value_get_type(value) != FL_VALUE_TYPE_STRING) return FALSE; - guint year = 0; - guint month = 0; - guint day = 0; - return parse_date(fl_value_get_string(value), &year, &month, &day); -} - -static gboolean native_schedule_filter_validate_item_list( - FlValue* list, - const gchar* const* string_keys, - gsize string_key_count, - gboolean requires_selected) { - if (list == nullptr) return FALSE; - for (size_t index = 0; index < fl_value_get_length(list); index++) { - FlValue* item = fl_value_get_list_value(list, index); - if (item == nullptr || fl_value_get_type(item) != FL_VALUE_TYPE_MAP) { - return FALSE; - } - for (gsize key_index = 0; key_index < string_key_count; key_index++) { - if (!native_schedule_filter_has_string(item, string_keys[key_index])) { - return FALSE; - } - } - if (requires_selected) { - gboolean selected = FALSE; - if (!fl_lookup_optional_bool_arg(item, "selected", &selected)) { - return FALSE; - } - (void)selected; - } - } - return TRUE; -} - -static gboolean validate_native_schedule_filter_state(FlValue* args, - const gchar** error) { - if (args == nullptr || fl_value_get_type(args) != FL_VALUE_TYPE_MAP) { - *error = "The filter state must be a map."; - return FALSE; - } - gint64 schema_version = 0; - if (!fl_lookup_int_arg(args, "schemaVersion", &schema_version) || - schema_version != kNativeScheduleSearchFilterSchemaVersion) { - *error = "The filter state schema version is unsupported."; - return FALSE; - } - gboolean active = FALSE; - gboolean sidebar_visible = FALSE; - if (!fl_lookup_optional_bool_arg(args, "active", &active) || - !fl_lookup_optional_bool_arg(args, "sidebarVisible", - &sidebar_visible)) { - *error = "The filter visibility state is invalid."; - return FALSE; - } - (void)sidebar_visible; - gdouble sidebar_width = 0; - if (!fl_lookup_double_arg(args, "sidebarWidth", &sidebar_width) || - sidebar_width < 160 || sidebar_width > 800) { - *error = "The filter sidebar width is invalid."; - return FALSE; - } - FlValue* labels = fl_lookup_map_arg(args, "labels"); - constexpr const gchar* label_keys[] = { - "searchFilters", "type", "all", "events", - "tasks", "date", "anyDate", "today", - "tomorrow", "thisWeek", "customRange", "startDate", - "endDate", "taskStatus", "open", "completed", - "taskDue", "anyDueState", "overdue", "noDueDate", - "person", "location", "sources", "clearFilters", - "noSources", "close", "cancel", "ok", - }; - if (labels == nullptr) { - *error = "The localized filter labels are missing."; - return FALSE; - } - for (const gchar* key : label_keys) { - if (!native_schedule_filter_has_string(labels, key)) { - *error = "A localized filter label is invalid."; - return FALSE; - } - } - constexpr const gchar* account_keys[] = {"id", "label"}; - constexpr const gchar* source_keys[] = {"id", "accountId", "title"}; - constexpr const gchar* task_list_keys[] = {"accountId", "taskListId", - "title"}; - if (!native_schedule_filter_validate_item_list( - fl_lookup_list_arg(args, "accounts"), account_keys, - G_N_ELEMENTS(account_keys), FALSE) || - !native_schedule_filter_validate_item_list( - fl_lookup_list_arg(args, "calendarSources"), source_keys, - G_N_ELEMENTS(source_keys), TRUE) || - !native_schedule_filter_validate_item_list( - fl_lookup_list_arg(args, "taskLists"), task_list_keys, - G_N_ELEMENTS(task_list_keys), TRUE)) { - *error = "The filter source presentation is invalid."; - return FALSE; - } - FlValue* criteria = fl_lookup_map_arg(args, "criteria"); - if (!active && criteria == nullptr) return TRUE; - if (criteria == nullptr) { - *error = "Active filters require criteria."; - return FALSE; - } - constexpr const gchar* types[] = {"all", "events", "tasks"}; - constexpr const gchar* dates[] = {"any", "today", "tomorrow", "thisWeek", - "custom"}; - constexpr const gchar* completion[] = {"open", "all", "completed"}; - constexpr const gchar* due_states[] = {"any", "overdue", "noDueDate"}; - if (!native_schedule_filter_valid_enum( - fl_lookup_string_arg(criteria, "type"), types, - G_N_ELEMENTS(types)) || - !native_schedule_filter_valid_enum( - fl_lookup_string_arg(criteria, "date"), dates, - G_N_ELEMENTS(dates)) || - !native_schedule_filter_valid_enum( - fl_lookup_string_arg(criteria, "taskCompletion"), completion, - G_N_ELEMENTS(completion)) || - !native_schedule_filter_valid_enum( - fl_lookup_string_arg(criteria, "taskDueState"), due_states, - G_N_ELEMENTS(due_states)) || - !native_schedule_filter_has_string(criteria, "person") || - !native_schedule_filter_has_string(criteria, "location") || - !native_schedule_filter_valid_optional_date(criteria, "customStart") || - !native_schedule_filter_valid_optional_date(criteria, "customEnd") || - !native_schedule_filter_valid_optional_date(criteria, "referenceDate") || - fl_lookup_list_arg(criteria, "sourceIds") == nullptr || - fl_lookup_list_arg(criteria, "taskListKeys") == nullptr) { - *error = "The filter criteria are invalid."; - return FALSE; - } - return TRUE; -} - -static void invoke_native_schedule_filter_event(MyApplication* self, - const gchar* method, - FlValue* arguments) { - if (self->suppress_schedule_filter_events || - self->native_schedule_search_filters_channel == nullptr || - method == nullptr) { - return; - } - fl_method_channel_invoke_method( - self->native_schedule_search_filters_channel, method, arguments, - nullptr, nullptr, nullptr); -} - -static void invoke_native_schedule_filter_string_event( - MyApplication* self, - const gchar* method, - const gchar* value) { - g_autoptr(FlValue) argument = - fl_value_new_string(value == nullptr ? "" : value); - invoke_native_schedule_filter_event(self, method, argument); -} - -static const gchar* native_schedule_filter_combo_value(GtkComboBox* combo, - const gchar* kind) { - const gint index = gtk_combo_box_get_active(combo); - if (g_strcmp0(kind, "type") == 0) { - constexpr const gchar* values[] = {"all", "events", "tasks"}; - return index >= 0 && index < static_cast(G_N_ELEMENTS(values)) - ? values[index] - : nullptr; - } - if (g_strcmp0(kind, "date") == 0) { - constexpr const gchar* values[] = {"any", "today", "tomorrow", - "thisWeek", "custom"}; - return index >= 0 && index < static_cast(G_N_ELEMENTS(values)) - ? values[index] - : nullptr; - } - if (g_strcmp0(kind, "completion") == 0) { - constexpr const gchar* values[] = {"open", "all", "completed"}; - return index >= 0 && index < static_cast(G_N_ELEMENTS(values)) - ? values[index] - : nullptr; - } - constexpr const gchar* values[] = {"any", "overdue", "noDueDate"}; - return index >= 0 && index < static_cast(G_N_ELEMENTS(values)) - ? values[index] - : nullptr; -} - -static void native_schedule_filter_combo_changed_cb(GtkComboBox* combo, - gpointer user_data) { - auto* controls = static_cast(user_data); - const gchar* kind = static_cast( - g_object_get_data(G_OBJECT(combo), "busymax-filter-kind")); - const gchar* value = native_schedule_filter_combo_value(combo, kind); - if (value == nullptr) return; - const gchar* method = g_strcmp0(kind, "type") == 0 - ? "typeChanged" - : g_strcmp0(kind, "date") == 0 - ? "dateChanged" - : g_strcmp0(kind, "completion") == 0 - ? "taskCompletionChanged" - : "taskDueStateChanged"; - invoke_native_schedule_filter_string_event(controls->application, method, - value); -} - -static void native_schedule_filter_entry_changed_cb(GtkEditable* entry, - gpointer user_data) { - auto* controls = static_cast(user_data); - const gchar* kind = static_cast( - g_object_get_data(G_OBJECT(entry), "busymax-filter-kind")); - invoke_native_schedule_filter_string_event( - controls->application, - g_strcmp0(kind, "person") == 0 ? "personChanged" : "locationChanged", - gtk_entry_get_text(GTK_ENTRY(entry))); -} - -static void native_schedule_filter_source_toggled_cb(GtkToggleButton* button, - gpointer user_data) { - auto* source_row = static_cast(user_data); - auto* controls = static_cast( - g_object_get_data(G_OBJECT(button), "busymax-filter-controls")); - if (controls == nullptr || source_row == nullptr) return; - g_autoptr(FlValue) args = fl_value_new_map(); - if (source_row->is_task_list) { - fl_value_set_string_take( - args, "accountId", - fl_value_new_string(source_row->account_id == nullptr - ? "" - : source_row->account_id)); - fl_value_set_string_take( - args, "taskListId", - fl_value_new_string(source_row->task_list_id == nullptr - ? "" - : source_row->task_list_id)); - } else { - fl_value_set_string_take( - args, "sourceId", - fl_value_new_string(source_row->source_id == nullptr - ? "" - : source_row->source_id)); - } - fl_value_set_string_take( - args, "selected", - fl_value_new_bool(gtk_toggle_button_get_active(button))); - invoke_native_schedule_filter_event( - controls->application, - source_row->is_task_list ? "taskListToggled" : "calendarSourceToggled", - args); -} - -static void native_schedule_filter_clear_clicked_cb(GtkButton*, - gpointer user_data) { - auto* controls = static_cast(user_data); - invoke_native_schedule_filter_event(controls->application, "clearFilters", - nullptr); -} - -static gchar* native_schedule_filter_format_date(const gchar* value) { - guint year = 0; - guint month = 0; - guint day = 0; - if (!parse_date(value, &year, &month, &day)) { - return g_strdup(value == nullptr ? "" : value); - } - g_autoptr(GDateTime) date = - g_date_time_new_local(year, month, day, 12, 0, 0); - return date == nullptr ? g_strdup(value) : g_date_time_format(date, "%x"); -} - -static const gchar* native_schedule_filter_date_value(FlValue* criteria, - const gchar* key) { - FlValue* value = criteria == nullptr - ? nullptr - : fl_value_lookup_string(criteria, key); - return value != nullptr && fl_value_get_type(value) == FL_VALUE_TYPE_STRING - ? fl_value_get_string(value) - : nullptr; -} - -static void native_schedule_filter_date_clicked_cb(GtkButton* button, - gpointer user_data) { - auto* controls = static_cast(user_data); - MyApplication* self = controls->application; - FlValue* criteria = native_schedule_filter_criteria(self); - FlValue* labels = self->schedule_filter_state == nullptr - ? nullptr - : fl_lookup_map_arg(self->schedule_filter_state, - "labels"); - if (criteria == nullptr || labels == nullptr) return; - const gboolean is_start = - g_strcmp0(static_cast(g_object_get_data( - G_OBJECT(button), "busymax-filter-date-kind")), - "start") == 0; - const gchar* value = native_schedule_filter_date_value( - criteria, is_start ? "customStart" : "customEnd"); - if (value == nullptr) { - value = native_schedule_filter_date_value(criteria, "referenceDate"); - } - GtkWidget* toplevel = gtk_widget_get_toplevel(GTK_WIDGET(button)); - GtkWindow* parent = GTK_IS_WINDOW(toplevel) ? GTK_WINDOW(toplevel) - : self->main_window; - g_autofree gchar* result = run_native_date_picker( - parent, - fl_lookup_string_arg(labels, is_start ? "startDate" : "endDate"), - value, fl_lookup_string_arg(labels, "cancel"), - fl_lookup_string_arg(labels, "ok")); - if (result != nullptr) { - invoke_native_schedule_filter_string_event( - self, is_start ? "customStartChanged" : "customEndChanged", result); - } -} - -static GtkWidget* native_schedule_filter_action_row(const gchar* kind, - GtkWidget* control, - GCallback callback, - gpointer user_data) { - GtkWidget* row = hdy_action_row_new(); - gtk_widget_set_hexpand(row, TRUE); - gtk_widget_set_halign(row, GTK_ALIGN_FILL); - gtk_widget_set_valign(control, GTK_ALIGN_CENTER); - g_object_set_data_full(G_OBJECT(control), "busymax-filter-kind", - g_strdup(kind), g_free); - gtk_container_add(GTK_CONTAINER(row), control); - hdy_action_row_set_activatable_widget(HDY_ACTION_ROW(row), control); - gtk_list_box_row_set_activatable(GTK_LIST_BOX_ROW(row), TRUE); - g_signal_connect(control, - GTK_IS_COMBO_BOX(control) ? "changed" : "search-changed", - callback, user_data); - return row; -} - -static void native_schedule_filter_controls_destroyed_cb(GtkWidget*, - gpointer user_data) { - auto* controls = static_cast(user_data); - MyApplication* self = controls->application; - if (self->schedule_filter_sidebar == controls) { - self->schedule_filter_sidebar = nullptr; - } - if (self->schedule_filter_modal == controls) { - self->schedule_filter_modal = nullptr; - if (self->main_window != nullptr) { - gtk_window_present(self->main_window); - } - } - native_schedule_filter_controls_free(controls); -} - -static NativeScheduleFilterControls* create_native_schedule_filter_controls( - MyApplication* self, - gboolean modal) { - auto* controls = g_new0(NativeScheduleFilterControls, 1); - controls->application = self; - controls->modal = modal; - controls->source_rows = g_ptr_array_new_with_free_func( - native_schedule_filter_source_row_free); - - controls->root = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0); - gtk_style_context_add_class(gtk_widget_get_style_context(controls->root), - GTK_STYLE_CLASS_BACKGROUND); - gtk_widget_set_hexpand(controls->root, FALSE); - gtk_widget_set_vexpand(controls->root, TRUE); - g_signal_connect(controls->root, "destroy", - G_CALLBACK(native_schedule_filter_controls_destroyed_cb), - controls); - - controls->scrolled_window = gtk_scrolled_window_new(nullptr, nullptr); - gtk_scrolled_window_set_policy( - GTK_SCROLLED_WINDOW(controls->scrolled_window), GTK_POLICY_NEVER, - GTK_POLICY_AUTOMATIC); - gtk_scrolled_window_set_shadow_type( - GTK_SCROLLED_WINDOW(controls->scrolled_window), GTK_SHADOW_NONE); - gtk_container_add(GTK_CONTAINER(controls->root), controls->scrolled_window); - - controls->content = gtk_box_new(GTK_ORIENTATION_VERTICAL, 12); - gtk_container_set_border_width(GTK_CONTAINER(controls->content), 12); - gtk_container_add(GTK_CONTAINER(controls->scrolled_window), - controls->content); - - controls->title_label = gtk_label_new(nullptr); - gtk_widget_set_halign(controls->title_label, GTK_ALIGN_START); - gtk_style_context_add_class( - gtk_widget_get_style_context(controls->title_label), "title"); - gtk_box_pack_start(GTK_BOX(controls->content), controls->title_label, FALSE, - FALSE, 0); - - controls->general_group = hdy_preferences_group_new(); - gtk_widget_set_hexpand(controls->general_group, TRUE); - gtk_box_pack_start(GTK_BOX(controls->content), controls->general_group, - FALSE, FALSE, 0); - - controls->type_combo = gtk_combo_box_text_new(); - controls->type_row = native_schedule_filter_action_row( - "type", controls->type_combo, - G_CALLBACK(native_schedule_filter_combo_changed_cb), controls); - gtk_container_add(GTK_CONTAINER(controls->general_group), - controls->type_row); - controls->date_combo = gtk_combo_box_text_new(); - controls->date_row = native_schedule_filter_action_row( - "date", controls->date_combo, - G_CALLBACK(native_schedule_filter_combo_changed_cb), controls); - gtk_container_add(GTK_CONTAINER(controls->general_group), - controls->date_row); - - controls->start_date_button = gtk_button_new(); - g_object_set_data_full(G_OBJECT(controls->start_date_button), - "busymax-filter-date-kind", g_strdup("start"), - g_free); - controls->start_date_row = hdy_action_row_new(); - gtk_container_add(GTK_CONTAINER(controls->start_date_row), - controls->start_date_button); - hdy_action_row_set_activatable_widget( - HDY_ACTION_ROW(controls->start_date_row), controls->start_date_button); - g_signal_connect(controls->start_date_button, "clicked", - G_CALLBACK(native_schedule_filter_date_clicked_cb), - controls); - gtk_container_add(GTK_CONTAINER(controls->general_group), - controls->start_date_row); - - controls->end_date_button = gtk_button_new(); - g_object_set_data_full(G_OBJECT(controls->end_date_button), - "busymax-filter-date-kind", g_strdup("end"), - g_free); - controls->end_date_row = hdy_action_row_new(); - gtk_container_add(GTK_CONTAINER(controls->end_date_row), - controls->end_date_button); - hdy_action_row_set_activatable_widget( - HDY_ACTION_ROW(controls->end_date_row), controls->end_date_button); - g_signal_connect(controls->end_date_button, "clicked", - G_CALLBACK(native_schedule_filter_date_clicked_cb), - controls); - gtk_container_add(GTK_CONTAINER(controls->general_group), - controls->end_date_row); - - controls->task_completion_combo = gtk_combo_box_text_new(); - controls->task_completion_row = native_schedule_filter_action_row( - "completion", controls->task_completion_combo, - G_CALLBACK(native_schedule_filter_combo_changed_cb), controls); - gtk_container_add(GTK_CONTAINER(controls->general_group), - controls->task_completion_row); - controls->task_due_combo = gtk_combo_box_text_new(); - controls->task_due_row = native_schedule_filter_action_row( - "due", controls->task_due_combo, - G_CALLBACK(native_schedule_filter_combo_changed_cb), controls); - gtk_container_add(GTK_CONTAINER(controls->general_group), - controls->task_due_row); - - controls->person_entry = gtk_search_entry_new(); - controls->person_row = native_schedule_filter_action_row( - "person", controls->person_entry, - G_CALLBACK(native_schedule_filter_entry_changed_cb), controls); - gtk_container_add(GTK_CONTAINER(controls->general_group), - controls->person_row); - controls->location_entry = gtk_search_entry_new(); - controls->location_row = native_schedule_filter_action_row( - "location", controls->location_entry, - G_CALLBACK(native_schedule_filter_entry_changed_cb), controls); - gtk_container_add(GTK_CONTAINER(controls->general_group), - controls->location_row); - - controls->sources_label = gtk_label_new(nullptr); - gtk_widget_set_halign(controls->sources_label, GTK_ALIGN_START); - gtk_style_context_add_class( - gtk_widget_get_style_context(controls->sources_label), "heading"); - gtk_box_pack_start(GTK_BOX(controls->content), controls->sources_label, - FALSE, FALSE, 0); - controls->sources_box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 12); - gtk_widget_set_hexpand(controls->sources_box, TRUE); - gtk_box_pack_start(GTK_BOX(controls->content), controls->sources_box, FALSE, - FALSE, 0); - controls->no_sources_label = gtk_label_new(nullptr); - gtk_label_set_line_wrap(GTK_LABEL(controls->no_sources_label), TRUE); - gtk_widget_set_halign(controls->no_sources_label, GTK_ALIGN_START); - gtk_box_pack_start(GTK_BOX(controls->content), controls->no_sources_label, - FALSE, FALSE, 0); - controls->clear_button = gtk_button_new(); - gtk_widget_set_halign(controls->clear_button, GTK_ALIGN_START); - g_signal_connect(controls->clear_button, "clicked", - G_CALLBACK(native_schedule_filter_clear_clicked_cb), - controls); - gtk_box_pack_start(GTK_BOX(controls->content), controls->clear_button, FALSE, - FALSE, 0); - - return controls; -} - -static gchar* native_schedule_filter_topology_signature(FlValue* state) { - GString* signature = g_string_new(nullptr); - constexpr const gchar* list_names[] = {"accounts", "calendarSources", - "taskLists"}; - constexpr const gchar* keys[][3] = {{"id", "label", nullptr}, - {"id", "accountId", "title"}, - {"accountId", "taskListId", "title"}}; - for (gsize list_index = 0; list_index < G_N_ELEMENTS(list_names); - list_index++) { - FlValue* list = fl_lookup_list_arg(state, list_names[list_index]); - g_string_append_printf(signature, "%s:%zu;", list_names[list_index], - fl_value_get_length(list)); - for (size_t index = 0; index < fl_value_get_length(list); index++) { - FlValue* item = fl_value_get_list_value(list, index); - for (const gchar* key : keys[list_index]) { - if (key == nullptr) continue; - const gchar* value = fl_lookup_string_arg(item, key); - g_string_append_printf(signature, "%zu:%s;", strlen(value), value); - } - } - } - return g_string_free(signature, FALSE); -} - -static GtkWidget* native_schedule_filter_account_group( - NativeScheduleFilterControls* controls, - const gchar* title) { - GtkWidget* group = hdy_preferences_group_new(); - gtk_widget_set_hexpand(group, TRUE); - gtk_widget_set_halign(group, GTK_ALIGN_FILL); - hdy_preferences_group_set_title(HDY_PREFERENCES_GROUP(group), title); - gtk_box_pack_start(GTK_BOX(controls->sources_box), group, FALSE, FALSE, 0); - return group; -} - -static void native_schedule_filter_add_source_row( - NativeScheduleFilterControls* controls, - GtkWidget* group, - FlValue* item, - gboolean is_task_list) { - auto* source_row = g_new0(NativeScheduleFilterSourceRow, 1); - source_row->group = group; - source_row->is_task_list = is_task_list; - source_row->account_id = - g_strdup(fl_lookup_string_arg(item, "accountId")); - source_row->source_id = is_task_list - ? nullptr - : g_strdup(fl_lookup_string_arg(item, "id")); - source_row->task_list_id = - is_task_list ? g_strdup(fl_lookup_string_arg(item, "taskListId")) - : nullptr; - source_row->row = hdy_action_row_new(); - hdy_preferences_row_set_title( - HDY_PREFERENCES_ROW(source_row->row), - fl_lookup_string_arg(item, "title")); - source_row->check_button = gtk_check_button_new(); - atk_object_set_name( - gtk_widget_get_accessible(source_row->check_button), - fl_lookup_string_arg(item, "title")); - g_object_set_data(G_OBJECT(source_row->check_button), - "busymax-filter-controls", controls); - gtk_container_add(GTK_CONTAINER(source_row->row), - source_row->check_button); - hdy_action_row_set_activatable_widget(HDY_ACTION_ROW(source_row->row), - source_row->check_button); - gtk_list_box_row_set_activatable(GTK_LIST_BOX_ROW(source_row->row), TRUE); - gtk_container_add(GTK_CONTAINER(group), source_row->row); - g_ptr_array_add(controls->source_rows, source_row); - g_signal_connect(source_row->check_button, "toggled", - G_CALLBACK(native_schedule_filter_source_toggled_cb), - source_row); -} - -static void native_schedule_filter_rebuild_sources( - NativeScheduleFilterControls* controls, - FlValue* state) { - g_autofree gchar* signature = - native_schedule_filter_topology_signature(state); - if (g_strcmp0(signature, controls->topology_signature) == 0) return; - g_free(controls->topology_signature); - controls->topology_signature = g_strdup(signature); - GList* children = gtk_container_get_children( - GTK_CONTAINER(controls->sources_box)); - for (GList* child = children; child != nullptr; child = child->next) { - gtk_widget_destroy(GTK_WIDGET(child->data)); - } - g_list_free(children); - g_ptr_array_set_size(controls->source_rows, 0); - - FlValue* accounts = fl_lookup_list_arg(state, "accounts"); - FlValue* calendar_sources = fl_lookup_list_arg(state, "calendarSources"); - FlValue* task_lists = fl_lookup_list_arg(state, "taskLists"); - for (size_t account_index = 0; - account_index < fl_value_get_length(accounts); account_index++) { - FlValue* account = fl_value_get_list_value(accounts, account_index); - const gchar* account_id = fl_lookup_string_arg(account, "id"); - GtkWidget* group = nullptr; - for (size_t index = 0; index < fl_value_get_length(calendar_sources); - index++) { - FlValue* source = fl_value_get_list_value(calendar_sources, index); - if (g_strcmp0(account_id, - fl_lookup_string_arg(source, "accountId")) != 0) { - continue; - } - if (group == nullptr) { - group = native_schedule_filter_account_group( - controls, fl_lookup_string_arg(account, "label")); - } - native_schedule_filter_add_source_row(controls, group, source, FALSE); - } - for (size_t index = 0; index < fl_value_get_length(task_lists); index++) { - FlValue* task_list = fl_value_get_list_value(task_lists, index); - if (g_strcmp0(account_id, - fl_lookup_string_arg(task_list, "accountId")) != 0) { - continue; - } - if (group == nullptr) { - group = native_schedule_filter_account_group( - controls, fl_lookup_string_arg(account, "label")); - } - native_schedule_filter_add_source_row(controls, group, task_list, TRUE); - } - } - gtk_widget_show_all(controls->sources_box); -} - -static gint native_schedule_filter_combo_index(const gchar* value, - const gchar* kind) { - if (g_strcmp0(kind, "type") == 0) { - if (g_strcmp0(value, "events") == 0) return 1; - if (g_strcmp0(value, "tasks") == 0) return 2; - return 0; - } - if (g_strcmp0(kind, "date") == 0) { - if (g_strcmp0(value, "today") == 0) return 1; - if (g_strcmp0(value, "tomorrow") == 0) return 2; - if (g_strcmp0(value, "thisWeek") == 0) return 3; - if (g_strcmp0(value, "custom") == 0) return 4; - return 0; - } - if (g_strcmp0(kind, "completion") == 0) { - if (g_strcmp0(value, "all") == 0) return 1; - if (g_strcmp0(value, "completed") == 0) return 2; - return 0; - } - if (g_strcmp0(value, "overdue") == 0) return 1; - if (g_strcmp0(value, "noDueDate") == 0) return 2; - return 0; -} - -static gchar* native_schedule_filter_labels_signature(FlValue* labels) { - GString* signature = g_string_new(nullptr); - constexpr const gchar* keys[] = { - "searchFilters", "type", "all", "events", - "tasks", "date", "anyDate", "today", - "tomorrow", "thisWeek", "customRange", "startDate", - "endDate", "taskStatus", "open", "completed", - "taskDue", "anyDueState", "overdue", "noDueDate", - "person", "location", "sources", "clearFilters", - "noSources"}; - for (const gchar* key : keys) { - const gchar* value = fl_lookup_string_arg(labels, key); - g_string_append_printf(signature, "%zu:%s;", strlen(value), value); - } - return g_string_free(signature, FALSE); -} - -static void native_schedule_filter_set_combo_labels( - GtkWidget* combo, - std::initializer_list labels) { - gtk_combo_box_text_remove_all(GTK_COMBO_BOX_TEXT(combo)); - for (const gchar* label : labels) { - gtk_combo_box_text_append_text(GTK_COMBO_BOX_TEXT(combo), label); - } -} - -static void native_schedule_filter_apply_labels( - NativeScheduleFilterControls* controls, - FlValue* labels) { - g_autofree gchar* signature = - native_schedule_filter_labels_signature(labels); - const gboolean combo_labels_changed = - g_strcmp0(signature, controls->labels_signature) != 0; - if (combo_labels_changed) { - g_free(controls->labels_signature); - controls->labels_signature = g_strdup(signature); - native_schedule_filter_set_combo_labels( - controls->type_combo, - {fl_lookup_string_arg(labels, "all"), - fl_lookup_string_arg(labels, "events"), - fl_lookup_string_arg(labels, "tasks")}); - native_schedule_filter_set_combo_labels( - controls->date_combo, - {fl_lookup_string_arg(labels, "anyDate"), - fl_lookup_string_arg(labels, "today"), - fl_lookup_string_arg(labels, "tomorrow"), - fl_lookup_string_arg(labels, "thisWeek"), - fl_lookup_string_arg(labels, "customRange")}); - native_schedule_filter_set_combo_labels( - controls->task_completion_combo, - {fl_lookup_string_arg(labels, "open"), - fl_lookup_string_arg(labels, "all"), - fl_lookup_string_arg(labels, "completed")}); - native_schedule_filter_set_combo_labels( - controls->task_due_combo, - {fl_lookup_string_arg(labels, "anyDueState"), - fl_lookup_string_arg(labels, "overdue"), - fl_lookup_string_arg(labels, "noDueDate")}); - } - gtk_label_set_text(GTK_LABEL(controls->title_label), - fl_lookup_string_arg(labels, "searchFilters")); - hdy_preferences_row_set_title(HDY_PREFERENCES_ROW(controls->type_row), - fl_lookup_string_arg(labels, "type")); - hdy_preferences_row_set_title(HDY_PREFERENCES_ROW(controls->date_row), - fl_lookup_string_arg(labels, "date")); - hdy_preferences_row_set_title( - HDY_PREFERENCES_ROW(controls->start_date_row), - fl_lookup_string_arg(labels, "startDate")); - hdy_preferences_row_set_title(HDY_PREFERENCES_ROW(controls->end_date_row), - fl_lookup_string_arg(labels, "endDate")); - hdy_preferences_row_set_title( - HDY_PREFERENCES_ROW(controls->task_completion_row), - fl_lookup_string_arg(labels, "taskStatus")); - hdy_preferences_row_set_title(HDY_PREFERENCES_ROW(controls->task_due_row), - fl_lookup_string_arg(labels, "taskDue")); - hdy_preferences_row_set_title(HDY_PREFERENCES_ROW(controls->person_row), - fl_lookup_string_arg(labels, "person")); - hdy_preferences_row_set_title(HDY_PREFERENCES_ROW(controls->location_row), - fl_lookup_string_arg(labels, "location")); - atk_object_set_name(gtk_widget_get_accessible(controls->type_combo), - fl_lookup_string_arg(labels, "type")); - atk_object_set_name(gtk_widget_get_accessible(controls->date_combo), - fl_lookup_string_arg(labels, "date")); - atk_object_set_name( - gtk_widget_get_accessible(controls->task_completion_combo), - fl_lookup_string_arg(labels, "taskStatus")); - atk_object_set_name(gtk_widget_get_accessible(controls->task_due_combo), - fl_lookup_string_arg(labels, "taskDue")); - gtk_entry_set_placeholder_text(GTK_ENTRY(controls->person_entry), - fl_lookup_string_arg(labels, "person")); - gtk_entry_set_placeholder_text(GTK_ENTRY(controls->location_entry), - fl_lookup_string_arg(labels, "location")); - atk_object_set_name(gtk_widget_get_accessible(controls->person_entry), - fl_lookup_string_arg(labels, "person")); - atk_object_set_name(gtk_widget_get_accessible(controls->location_entry), - fl_lookup_string_arg(labels, "location")); - gtk_label_set_text(GTK_LABEL(controls->sources_label), - fl_lookup_string_arg(labels, "sources")); - gtk_label_set_text(GTK_LABEL(controls->no_sources_label), - fl_lookup_string_arg(labels, "noSources")); - gtk_button_set_label(GTK_BUTTON(controls->clear_button), - fl_lookup_string_arg(labels, "clearFilters")); - atk_object_set_name(gtk_widget_get_accessible(controls->clear_button), - fl_lookup_string_arg(labels, "clearFilters")); -} - -static gboolean native_schedule_filter_item_selected( - FlValue* state, - NativeScheduleFilterSourceRow* row) { - FlValue* list = fl_lookup_list_arg( - state, row->is_task_list ? "taskLists" : "calendarSources"); - for (size_t index = 0; index < fl_value_get_length(list); index++) { - FlValue* item = fl_value_get_list_value(list, index); - const gboolean matches = row->is_task_list - ? g_strcmp0(row->account_id, - fl_lookup_string_arg(item, "accountId")) == 0 && - g_strcmp0(row->task_list_id, - fl_lookup_string_arg(item, "taskListId")) == 0 - : g_strcmp0(row->source_id, fl_lookup_string_arg(item, "id")) == 0; - if (matches) { - return fl_lookup_bool_arg(item, "selected", FALSE); - } - } - return FALSE; -} - -static void apply_native_schedule_filter_state_to_controls( - NativeScheduleFilterControls* controls, - FlValue* state) { - FlValue* criteria = fl_lookup_map_arg(state, "criteria"); - FlValue* labels = fl_lookup_map_arg(state, "labels"); - if (criteria == nullptr || labels == nullptr) return; - MyApplication* self = controls->application; - self->suppress_schedule_filter_events = TRUE; - native_schedule_filter_apply_labels(controls, labels); - native_schedule_filter_rebuild_sources(controls, state); - - const gchar* type = fl_lookup_string_arg(criteria, "type"); - const gchar* date = fl_lookup_string_arg(criteria, "date"); - gtk_combo_box_set_active( - GTK_COMBO_BOX(controls->type_combo), - native_schedule_filter_combo_index(type, "type")); - gtk_combo_box_set_active( - GTK_COMBO_BOX(controls->date_combo), - native_schedule_filter_combo_index(date, "date")); - gtk_combo_box_set_active( - GTK_COMBO_BOX(controls->task_completion_combo), - native_schedule_filter_combo_index( - fl_lookup_string_arg(criteria, "taskCompletion"), "completion")); - gtk_combo_box_set_active( - GTK_COMBO_BOX(controls->task_due_combo), - native_schedule_filter_combo_index( - fl_lookup_string_arg(criteria, "taskDueState"), "due")); - - const gchar* person = fl_lookup_string_arg(criteria, "person"); - const gchar* location = fl_lookup_string_arg(criteria, "location"); - if (g_strcmp0(gtk_entry_get_text(GTK_ENTRY(controls->person_entry)), - person) != 0) { - gtk_entry_set_text(GTK_ENTRY(controls->person_entry), person); - } - if (g_strcmp0(gtk_entry_get_text(GTK_ENTRY(controls->location_entry)), - location) != 0) { - gtk_entry_set_text(GTK_ENTRY(controls->location_entry), location); - } - - const gchar* start = native_schedule_filter_date_value(criteria, - "customStart"); - const gchar* end = native_schedule_filter_date_value(criteria, "customEnd"); - const gchar* reference = native_schedule_filter_date_value( - criteria, "referenceDate"); - g_autofree gchar* start_label = - native_schedule_filter_format_date(start == nullptr ? reference : start); - g_autofree gchar* end_label = - native_schedule_filter_format_date(end == nullptr ? reference : end); - gtk_button_set_label(GTK_BUTTON(controls->start_date_button), start_label); - gtk_button_set_label(GTK_BUTTON(controls->end_date_button), end_label); - atk_object_set_name( - gtk_widget_get_accessible(controls->start_date_button), - fl_lookup_string_arg(labels, "startDate")); - atk_object_set_name(gtk_widget_get_accessible(controls->end_date_button), - fl_lookup_string_arg(labels, "endDate")); - - const gboolean includes_events = g_strcmp0(type, "tasks") != 0; - const gboolean includes_tasks = g_strcmp0(type, "events") != 0; - const gboolean custom_date = g_strcmp0(date, "custom") == 0; - gtk_widget_set_visible(controls->start_date_row, custom_date); - gtk_widget_set_visible(controls->end_date_row, custom_date); - gtk_widget_set_visible(controls->task_completion_row, includes_tasks); - gtk_widget_set_visible(controls->task_due_row, includes_tasks); - gtk_widget_set_visible(controls->person_row, includes_events); - - gboolean has_selected_source = FALSE; - for (guint index = 0; index < controls->source_rows->len; index++) { - auto* source_row = static_cast( - g_ptr_array_index(controls->source_rows, index)); - gtk_widget_set_visible(source_row->group, FALSE); - } - for (guint index = 0; index < controls->source_rows->len; index++) { - auto* source_row = static_cast( - g_ptr_array_index(controls->source_rows, index)); - const gboolean visible = - source_row->is_task_list ? includes_tasks : includes_events; - const gboolean selected = - native_schedule_filter_item_selected(state, source_row); - gtk_toggle_button_set_active( - GTK_TOGGLE_BUTTON(source_row->check_button), selected); - gtk_widget_set_visible(source_row->row, visible); - if (visible) gtk_widget_set_visible(source_row->group, TRUE); - has_selected_source = has_selected_source || (visible && selected); - } - gtk_widget_set_visible(controls->no_sources_label, !has_selected_source); - self->suppress_schedule_filter_events = FALSE; -} - -static void hide_native_schedule_filter_modal(MyApplication* self) { - NativeScheduleFilterControls* controls = self->schedule_filter_modal; - if (controls != nullptr && controls->window != nullptr && - GTK_IS_WIDGET(controls->window)) { - gtk_widget_destroy(controls->window); - } -} - -static NativeScheduleFilterControls* ensure_native_schedule_filter_sidebar( - MyApplication* self) { - if (self->schedule_filter_sidebar != nullptr) { - return self->schedule_filter_sidebar; - } - if (self->content_overlay == nullptr || - !GTK_IS_OVERLAY(self->content_overlay)) { - return nullptr; - } - NativeScheduleFilterControls* controls = - create_native_schedule_filter_controls(self, FALSE); - self->schedule_filter_sidebar = controls; - gtk_widget_set_halign(controls->root, GTK_ALIGN_START); - gtk_widget_set_valign(controls->root, GTK_ALIGN_FILL); - gtk_overlay_add_overlay(GTK_OVERLAY(self->content_overlay), controls->root); - gtk_overlay_set_overlay_pass_through(GTK_OVERLAY(self->content_overlay), - controls->root, FALSE); - return controls; -} - -static void apply_native_schedule_filter_state(MyApplication* self) { - FlValue* state = self->schedule_filter_state; - if (state == nullptr) return; - const gboolean active = fl_lookup_bool_arg(state, "active", FALSE); - const gboolean sidebar_visible = - active && fl_lookup_bool_arg(state, "sidebarVisible", FALSE); - NativeScheduleFilterControls* sidebar = - ensure_native_schedule_filter_sidebar(self); - if (sidebar != nullptr) { - gdouble sidebar_width = 300; - fl_lookup_double_arg(state, "sidebarWidth", &sidebar_width); - gtk_widget_set_size_request( - sidebar->root, static_cast(sidebar_width), -1); - if (active) { - gtk_widget_show_all(sidebar->root); - apply_native_schedule_filter_state_to_controls(sidebar, state); - } - gtk_widget_set_visible(sidebar->root, sidebar_visible); - } - if (!active) { - hide_native_schedule_filter_modal(self); - return; - } - if (sidebar_visible) { - hide_native_schedule_filter_modal(self); - } - if (self->schedule_filter_modal != nullptr) { - apply_native_schedule_filter_state_to_controls( - self->schedule_filter_modal, state); - } -} - -static gboolean native_schedule_filter_modal_key_press_cb(GtkWidget* window, - GdkEventKey* event, - gpointer) { - if (event->keyval != GDK_KEY_Escape) return FALSE; - gtk_widget_destroy(window); - return TRUE; -} - -static gboolean show_native_schedule_filter_modal(MyApplication* self) { - if (self->schedule_filter_state == nullptr || - !fl_lookup_bool_arg(self->schedule_filter_state, "active", FALSE) || - self->main_window == nullptr) { - return FALSE; - } - if (self->schedule_filter_modal != nullptr) { - gtk_window_present(GTK_WINDOW(self->schedule_filter_modal->window)); - return TRUE; - } - NativeScheduleFilterControls* controls = - create_native_schedule_filter_controls(self, TRUE); - self->schedule_filter_modal = controls; - GtkWidget* window = hdy_window_new(); - controls->window = window; - style_native_dialog(window); - gtk_window_set_transient_for(GTK_WINDOW(window), self->main_window); - gtk_window_set_modal(GTK_WINDOW(window), TRUE); - gtk_window_set_destroy_with_parent(GTK_WINDOW(window), TRUE); - gtk_window_set_type_hint(GTK_WINDOW(window), GDK_WINDOW_TYPE_HINT_DIALOG); - gtk_window_set_skip_taskbar_hint(GTK_WINDOW(window), TRUE); - gtk_window_set_skip_pager_hint(GTK_WINDOW(window), TRUE); - gtk_window_set_position(GTK_WINDOW(window), GTK_WIN_POS_CENTER_ON_PARENT); - gtk_window_set_default_size(GTK_WINDOW(window), - kNativeScheduleSearchFilterDialogWidth, - kNativeScheduleSearchFilterDialogHeight); - gtk_window_set_resizable(GTK_WINDOW(window), TRUE); - - FlValue* labels = - fl_lookup_map_arg(self->schedule_filter_state, "labels"); - const gchar* title = fl_lookup_string_arg(labels, "searchFilters"); - gtk_window_set_title(GTK_WINDOW(window), title); - GtkWidget* window_root = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0); - gtk_container_add(GTK_CONTAINER(window), window_root); - GtkWidget* header = hdy_header_bar_new(); - hdy_header_bar_set_title(HDY_HEADER_BAR(header), title); - hdy_header_bar_set_has_subtitle(HDY_HEADER_BAR(header), FALSE); - hdy_header_bar_set_show_close_button(HDY_HEADER_BAR(header), TRUE); - hdy_header_bar_set_decoration_layout(HDY_HEADER_BAR(header), ":close"); - gtk_box_pack_start(GTK_BOX(window_root), header, FALSE, FALSE, 0); - gtk_box_pack_start(GTK_BOX(window_root), controls->root, TRUE, TRUE, 0); - g_signal_connect(window, "key-press-event", - G_CALLBACK(native_schedule_filter_modal_key_press_cb), - nullptr); - gtk_widget_show_all(window); - apply_native_schedule_filter_state_to_controls( - controls, self->schedule_filter_state); - gtk_widget_grab_focus(controls->type_combo); - gtk_window_present(GTK_WINDOW(window)); - return TRUE; -} - -static void native_schedule_search_filters_method_call_cb( - FlMethodChannel*, - FlMethodCall* method_call, - gpointer user_data) { - MyApplication* self = MY_APPLICATION(user_data); - const gchar* method = fl_method_call_get_name(method_call); - FlValue* args = fl_method_call_get_args(method_call); - if (g_strcmp0(method, "initialize") == 0) { - respond_bool(method_call, self->content_overlay != nullptr); - } else if (g_strcmp0(method, "setState") == 0) { - const gchar* error = nullptr; - if (!validate_native_schedule_filter_state(args, &error)) { - fl_method_call_respond_error(method_call, "invalid-arguments", error, - nullptr, nullptr); - return; - } - g_clear_pointer(&self->schedule_filter_state, fl_value_unref); - self->schedule_filter_state = fl_value_ref(args); - apply_native_schedule_filter_state(self); - respond_success(method_call); - } else if (g_strcmp0(method, "showModal") == 0) { - respond_bool(method_call, show_native_schedule_filter_modal(self)); - } else if (g_strcmp0(method, "hideModal") == 0) { - hide_native_schedule_filter_modal(self); - respond_success(method_call); - } else if (g_strcmp0(method, "hide") == 0) { - if (self->schedule_filter_sidebar != nullptr) { - gtk_widget_hide(self->schedule_filter_sidebar->root); - } - hide_native_schedule_filter_modal(self); - g_clear_pointer(&self->schedule_filter_state, fl_value_unref); - respond_success(method_call); - } else { - fl_method_call_respond_not_implemented(method_call, nullptr); - } -} - -static void register_native_schedule_search_filters(MyApplication* self, - FlView* view) { - g_autoptr(FlStandardMethodCodec) codec = fl_standard_method_codec_new(); - self->native_schedule_search_filters_channel = fl_method_channel_new( - fl_engine_get_binary_messenger(fl_view_get_engine(view)), - kNativeScheduleSearchFiltersChannel, FL_METHOD_CODEC(codec)); - fl_method_channel_set_method_call_handler( - self->native_schedule_search_filters_channel, - native_schedule_search_filters_method_call_cb, self, nullptr); -} - static gboolean fl_method_bool_arg(FlValue* args) { return args != nullptr && fl_value_get_type(args) == FL_VALUE_TYPE_BOOL ? fl_value_get_bool(args) @@ -4612,41 +3479,6 @@ static gboolean focus_header_search_entry(MyApplication* self) { return gtk_widget_has_focus(self->search_entry); } -static gboolean native_schedule_filter_window_key_press_cb( - GtkWindow* window, - GdkEventKey* event, - gpointer user_data) { - MyApplication* self = MY_APPLICATION(user_data); - if (self->schedule_filter_state == nullptr || - !fl_lookup_bool_arg(self->schedule_filter_state, "active", FALSE) || - self->schedule_filter_modal != nullptr) { - return FALSE; - } - const GdkModifierType modifiers = static_cast( - event->state & gtk_accelerator_get_default_mod_mask()); - if ((event->keyval == GDK_KEY_f || event->keyval == GDK_KEY_F) && - (modifiers & GDK_CONTROL_MASK) != 0) { - return focus_header_search_entry(self); - } - if (event->keyval == GDK_KEY_F9) { - invoke_header_bar_action(self, "sidebarToggle"); - return TRUE; - } - if (event->keyval != GDK_KEY_Escape || - self->schedule_filter_sidebar == nullptr || - !gtk_widget_get_visible(self->schedule_filter_sidebar->root)) { - return FALSE; - } - GtkWidget* focus = gtk_window_get_focus(window); - if (focus == nullptr || - !gtk_widget_is_ancestor(focus, - self->schedule_filter_sidebar->root)) { - return FALSE; - } - invoke_native_schedule_filter_event(self, "dismissSearch", nullptr); - return TRUE; -} - static void set_header_search_state(MyApplication* self, gboolean active, const gchar* query) { @@ -6245,9 +5077,6 @@ static void my_application_activate(GApplication* application) { g_signal_connect( window, "notify::is-active", G_CALLBACK(header_focus_window_is_active_notify_cb), self); - g_signal_connect(window, "key-press-event", - G_CALLBACK(native_schedule_filter_window_key_press_cb), - self); g_autoptr(FlDartProject) project = fl_dart_project_new(); fl_dart_project_set_dart_entrypoint_arguments( @@ -6261,13 +5090,7 @@ static void my_application_activate(GApplication* application) { GtkWidget* window_content = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0); gtk_box_pack_start(GTK_BOX(window_content), titlebar_handle, FALSE, FALSE, 0); - track_widget_pointer(&self->content_overlay, gtk_overlay_new()); - gtk_widget_set_hexpand(self->content_overlay, TRUE); - gtk_widget_set_vexpand(self->content_overlay, TRUE); - gtk_container_add(GTK_CONTAINER(self->content_overlay), GTK_WIDGET(view)); - gtk_widget_show(self->content_overlay); - gtk_box_pack_start(GTK_BOX(window_content), self->content_overlay, TRUE, TRUE, - 0); + gtk_box_pack_start(GTK_BOX(window_content), GTK_WIDGET(view), TRUE, TRUE, 0); gtk_widget_show(window_content); gtk_container_add(GTK_CONTAINER(window), window_content); @@ -6281,7 +5104,6 @@ static void my_application_activate(GApplication* application) { register_native_date_time_picker(self, view, window); register_native_dialogs(self, view, window); register_native_menus(self, view); - register_native_schedule_search_filters(self, view); register_external_calendar_open_channel(self, view); register_external_uri_launcher_channel(self, view); register_window_channel(self, view); @@ -6375,13 +5197,6 @@ static void my_application_shutdown(GApplication* application) { static void my_application_dispose(GObject* object) { MyApplication* self = MY_APPLICATION(object); disconnect_gtk_theme_colors_signals(self); - hide_native_schedule_filter_modal(self); - if (self->schedule_filter_sidebar != nullptr && - self->schedule_filter_sidebar->root != nullptr && - GTK_IS_WIDGET(self->schedule_filter_sidebar->root)) { - gtk_widget_destroy(self->schedule_filter_sidebar->root); - } - g_clear_pointer(&self->schedule_filter_state, fl_value_unref); if (self->header_bar_css_provider != nullptr) { gtk_style_context_remove_provider_for_screen( gdk_screen_get_default(), GTK_STYLE_PROVIDER(self->header_bar_css_provider)); @@ -6390,7 +5205,6 @@ static void my_application_dispose(GObject* object) { g_clear_object(&self->native_date_time_picker_channel); g_clear_object(&self->native_dialog_channel); g_clear_object(&self->native_menu_channel); - g_clear_object(&self->native_schedule_search_filters_channel); g_clear_object(&self->window_channel); g_clear_object(&self->header_bar_channel); g_clear_object(&self->gtk_settings_channel); @@ -6416,7 +5230,6 @@ static void my_application_dispose(GObject* object) { } self->main_window = nullptr; clear_widget_pointer(&self->flutter_view); - clear_widget_pointer(&self->content_overlay); clear_widget_pointer(&self->titlebar_handle); clear_widget_pointer(&self->titlebar_overlay); clear_widget_pointer(&self->titlebar_modal_barrier); @@ -6509,7 +5322,6 @@ static void my_application_init(MyApplication* self) { self->native_date_time_picker_channel = nullptr; self->native_dialog_channel = nullptr; self->native_menu_channel = nullptr; - self->native_schedule_search_filters_channel = nullptr; self->window_channel = nullptr; self->header_bar_channel = nullptr; self->gtk_settings_channel = nullptr; @@ -6563,7 +5375,6 @@ static void my_application_init(MyApplication* self) { self->main_window = nullptr; self->header_focus_transient_window = nullptr; self->flutter_view = nullptr; - self->content_overlay = nullptr; self->titlebar_handle = nullptr; self->titlebar_overlay = nullptr; self->titlebar_modal_barrier = nullptr; @@ -6627,10 +5438,6 @@ static void my_application_init(MyApplication* self) { self->header_search_query = g_strdup(""); self->header_search_active = FALSE; self->header_navigation_visible = TRUE; - self->schedule_filter_sidebar = nullptr; - self->schedule_filter_modal = nullptr; - self->schedule_filter_state = nullptr; - self->suppress_schedule_filter_events = FALSE; } MyApplication* my_application_new() { diff --git a/test/app/native_ui_audit_test.dart b/test/app/native_ui_audit_test.dart index 6363cf1c..e51176dd 100644 --- a/test/app/native_ui_audit_test.dart +++ b/test/app/native_ui_audit_test.dart @@ -1344,55 +1344,48 @@ void main() { expect(confirmBody, isNot(contains('return AlertDialog('))); }); - test('schedule search filters are native GTK and Handy on Linux', () { - final runner = File('linux/runner/my_application.cc').readAsStringSync(); - final workspace = File( - 'lib/src/features/schedule/presentation/schedule_workspace.dart', - ).readAsStringSync(); - final service = File( - 'lib/src/platform/linux_schedule_search_filter_service.dart', - ).readAsStringSync(); - - expect(runner, contains('busymax/native_schedule_search_filters')); - expect(runner, contains('gtk_overlay_new()')); - expect(runner, contains('gtk_overlay_add_overlay(')); - expect(runner, contains('gtk_overlay_set_overlay_pass_through(')); - expect(runner, contains('gtk_scrolled_window_new(')); - expect(runner, contains('hdy_preferences_group_new()')); - expect(runner, contains('hdy_action_row_new()')); - expect(runner, contains('gtk_combo_box_text_new()')); - expect(runner, contains('gtk_search_entry_new()')); - expect(runner, contains('gtk_check_button_new()')); - expect( - runner, - contains( - 'gtk_style_context_add_class(gtk_widget_get_style_context(controls->root),\n' - ' GTK_STYLE_CLASS_BACKGROUND);', - ), - ); - expect( - runner, - isNot( - contains( - 'gtk_style_context_add_class(gtk_widget_get_style_context(controls->root),\n' - ' "sidebar");', - ), - ), - ); - expect(runner, contains('show_native_schedule_filter_modal')); - expect(runner, contains('run_native_date_picker(')); - expect(runner, contains('self->suppress_schedule_filter_events = TRUE')); - expect(service, contains('class LinuxScheduleSearchFilterService')); - expect(workspace, contains('linux-native-search-filter-spacer')); - expect(workspace, isNot(contains('ScheduleSearchFilters('))); - expect(workspace, isNot(contains('showDialog('))); - expect( - File( + test( + 'schedule search filters use BusyMax rows without a dedicated native subsystem', + () { + final runner = File( + 'linux/runner/my_application.cc', + ).readAsStringSync(); + final workspace = File( + 'lib/src/features/schedule/presentation/schedule_workspace.dart', + ).readAsStringSync(); + final filters = File( 'lib/src/features/schedule/presentation/schedule_search_filters.dart', - ).existsSync(), - isFalse, - ); - }); + ).readAsStringSync(); + + expect(runner, isNot(contains('native_schedule_search_filters'))); + expect(runner, isNot(contains('NativeScheduleFilter'))); + expect(runner, isNot(contains('content_overlay'))); + expect( + File( + 'lib/src/platform/linux_schedule_search_filter_service.dart', + ).existsSync(), + isFalse, + ); + expect( + File( + 'test/platform/linux_schedule_search_filter_service_test.dart', + ).existsSync(), + isFalse, + ); + + expect(filters, contains('BusyMaxSidebarSurface(')); + expect(filters, contains('BusyMaxGroupedList(')); + expect(filters, contains('BusyMaxComboRow<')); + expect(filters, contains('BusyMaxSwitchRow(')); + expect(filters, contains('BusyMaxActionRow(')); + expect(filters, contains('DesktopDateValueRow(')); + expect(filters, contains('busyMaxGroupedTextFieldDecoration(')); + expect(workspace, contains('ScheduleSearchFilters(')); + expect(workspace, contains('showBusyMaxModalDialog(')); + expect(workspace, contains('BusyMaxDialogShell(')); + expect(workspace, isNot(contains('linux-native-search-filter-spacer'))); + }, + ); test('timezone selection uses native GTK and Handy controls on Linux', () { final runner = File('linux/runner/my_application.cc').readAsStringSync(); diff --git a/test/features/schedule/presentation/schedule_search_filters_test.dart b/test/features/schedule/presentation/schedule_search_filters_test.dart new file mode 100644 index 00000000..2054c045 --- /dev/null +++ b/test/features/schedule/presentation/schedule_search_filters_test.dart @@ -0,0 +1,332 @@ +import 'package:busymax/l10n/generated/app_localizations.dart'; +import 'package:busymax/src/app/busymax_design.dart'; +import 'package:busymax/src/features/accounts/data/accounts_repository.dart'; +import 'package:busymax/src/features/calendar/data/calendar_repository.dart'; +import 'package:busymax/src/features/schedule/presentation/schedule_search_filters.dart'; +import 'package:busymax/src/features/task_lists/data/task_lists_repository.dart'; +import 'package:busymax/src/features/tasks/presentation/desktop_date_time_fields.dart'; +import 'package:busymax/src/providers/busy_provider.dart'; +import 'package:busymax/src/schedule/schedule_filters.dart'; +import 'package:busymax/src/schedule/schedule_search_criteria.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:yaru/yaru.dart'; + +import '../../../test_localized_app.dart'; + +void main() { + testWidgets('uses the Settings-style BusyMax sidebar and grouped rows', ( + tester, + ) async { + final state = _FilterState( + _criteria( + date: ScheduleSearchDate.custom, + sourceIds: {'calendar'}, + taskListKeys: { + ScheduleTaskListKey(accountId: 'account', taskListId: 'tasks'), + }, + ), + ); + await _pumpFilters(tester, state: state); + + expect(find.byType(BusyMaxSidebarSurface), findsOneWidget); + expect(find.byType(BusyMaxGroupedList), findsNWidgets(4)); + expect(find.byType(BusyMaxComboRow), findsOneWidget); + expect(find.byType(BusyMaxComboRow), findsOneWidget); + expect( + find.byType(BusyMaxComboRow), + findsOneWidget, + ); + expect(find.byType(BusyMaxComboRow), findsOneWidget); + expect(find.byType(DesktopDateValueRow), findsNWidgets(2)); + expect(find.byType(BusyMaxSwitchRow), findsNWidgets(2)); + expect(find.byType(BusyMaxActionRow), findsOneWidget); + expect(find.text('Deleted calendar'), findsNothing); + expect(find.text('Deleted task list'), findsNothing); + }); + + testWidgets('uses grouped editor fields and conditional filter rows', ( + tester, + ) async { + final state = _FilterState( + _criteria( + sourceIds: {'calendar'}, + taskListKeys: { + ScheduleTaskListKey(accountId: 'account', taskListId: 'tasks'), + }, + ), + ); + await _pumpFilters(tester, state: state); + final l10n = AppLocalizations.of( + tester.element(find.byType(ScheduleSearchFilters)), + ); + + final personField = find.descendant( + of: find.byKey(const ValueKey('schedule-search-person')), + matching: find.byType(TextField), + ); + final locationField = find.descendant( + of: find.byKey(const ValueKey('schedule-search-location')), + matching: find.byType(TextField), + ); + expect(personField, findsOneWidget); + expect(locationField, findsOneWidget); + expect(tester.widget(personField).decoration, isNotNull); + + await tester.enterText(personField, 'Taylor'); + await tester.enterText(locationField, 'Office'); + await tester.pump(); + expect(state.value.person, 'Taylor'); + expect(state.value.location, 'Office'); + + _combo( + tester, + l10n.searchType, + ).onSelected(ScheduleSearchType.tasks); + await tester.pump(); + expect(find.byKey(const ValueKey('schedule-search-person')), findsNothing); + expect(state.value.person, isEmpty); + expect(_comboFinder(l10n.searchTaskStatus), findsOneWidget); + expect(_comboFinder(l10n.searchTaskDue), findsOneWidget); + + _combo( + tester, + l10n.searchType, + ).onSelected(ScheduleSearchType.events); + await tester.pump(); + expect( + find.byKey(const ValueKey('schedule-search-person')), + findsOneWidget, + ); + expect(_comboFinder(l10n.searchTaskStatus), findsNothing); + expect(_comboFinder(l10n.searchTaskDue), findsNothing); + expect(find.text('Work tasks'), findsNothing); + expect(find.text('Work calendar'), findsOneWidget); + }); + + testWidgets('source switches are temporary and Clear Filters delegates', ( + tester, + ) async { + var cleared = false; + final state = _FilterState( + _criteria( + sourceIds: {'calendar'}, + taskListKeys: { + ScheduleTaskListKey(accountId: 'account', taskListId: 'tasks'), + }, + ), + ); + await _pumpFilters(tester, state: state, onClear: () => cleared = true); + + tester + .widget( + find.byKey(const ValueKey(('schedule-search-source', 'calendar'))), + ) + .onChanged(false); + await tester.pump(); + expect(state.value.sourceIds, isEmpty); + expect(_sources.first.selected, isTrue); + + tester + .widget( + find.byKey( + const ValueKey(('schedule-search-task-list', 'account', 'tasks')), + ), + ) + .onChanged(false); + await tester.pump(); + expect(state.value.taskListKeys, isEmpty); + + final l10n = AppLocalizations.of( + tester.element(find.byType(ScheduleSearchFilters)), + ); + expect(find.text(l10n.searchNoSources), findsOneWidget); + await tester.tap( + find.byKey(const ValueKey('schedule-search-clear-filters')), + ); + expect(cleared, isTrue); + }); + + testWidgets('Linux custom range uses native desktop date fields', ( + tester, + ) async { + const channel = MethodChannel(nativeDateTimePickerChannelName); + final calls = []; + final responses = ['2026-06-15', '2026-06-12', null]; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + calls.add(call); + return responses.removeAt(0); + }); + addTearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); + }); + + final state = _FilterState( + _criteria( + date: ScheduleSearchDate.custom, + customStart: DateTime(2026, 6, 12), + customEnd: DateTime(2026, 6, 14), + ), + ); + await _pumpFilters(tester, state: state, includeSources: false); + expect(find.byType(DesktopDateField), findsNWidgets(2)); + final labels = AppLocalizations.of( + tester.element(find.byType(ScheduleSearchFilters)), + ); + + Future pickDate(String label) async { + final field = find.byWidgetPredicate( + (widget) => widget is DesktopDateField && widget.label == label, + ); + final button = find.descendant( + of: field, + matching: find.byType(YaruIconButton), + ); + await tester.ensureVisible(button); + await tester.tap(button); + await tester.pumpAndSettle(); + } + + await pickDate(labels.startDate); + expect(calls.single.method, 'pickDate'); + expect(calls.single.arguments, containsPair('initialDate', '2026-06-12')); + expect(state.value.customStart, DateTime(2026, 6, 15)); + expect(state.value.customEnd, DateTime(2026, 6, 15)); + expect(state.value.range!.end, DateTime(2026, 6, 16)); + + await pickDate(labels.endDate); + expect(calls.last.arguments, containsPair('initialDate', '2026-06-15')); + expect(state.value.customStart, DateTime(2026, 6, 12)); + expect(state.value.customEnd, DateTime(2026, 6, 12)); + expect(state.value.range!.end, DateTime(2026, 6, 13)); + + final beforeCancel = state.value; + await pickDate(labels.endDate); + expect(state.value, beforeCancel); + expect(find.byType(DatePickerDialog), findsNothing); + expect(tester.takeException(), isNull); + }); +} + +Finder _comboFinder(String title) => find.byWidgetPredicate( + (widget) => widget is BusyMaxComboRow && widget.title == title, +); + +BusyMaxComboRow _combo(WidgetTester tester, String title) => + tester.widget>( + find.byWidgetPredicate( + (widget) => widget is BusyMaxComboRow && widget.title == title, + ), + ); + +Future _pumpFilters( + WidgetTester tester, { + required _FilterState state, + VoidCallback? onClear, + bool includeSources = true, +}) async { + tester.view + ..physicalSize = const Size(400, 1600) + ..devicePixelRatio = 1; + addTearDown(tester.view.reset); + await tester.pumpWidget( + localizedTestApp( + child: Scaffold( + body: SizedBox( + width: BusyMaxSizes.sidebarWidth, + child: StatefulBuilder( + builder: (context, setState) => ScheduleSearchFilters( + value: state.value, + onChanged: (value) => setState(() => state.value = value), + onClear: onClear ?? () {}, + accounts: includeSources ? _accounts : const [], + sources: includeSources ? _sources : const [], + taskLists: includeSources ? _taskLists : const [], + ), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); +} + +ScheduleSearchCriteria _criteria({ + ScheduleSearchDate date = ScheduleSearchDate.any, + DateTime? customStart, + DateTime? customEnd, + Set sourceIds = const {}, + Set taskListKeys = const {}, +}) => ScheduleSearchCriteria( + referenceDate: DateTime(2026, 6, 12), + firstWeekday: DateTime.monday, + sourceIds: sourceIds, + taskListKeys: taskListKeys, + date: date, + customStart: customStart, + customEnd: customEnd, +); + +class _FilterState { + _FilterState(this.value); + + ScheduleSearchCriteria value; +} + +const _accounts = [ + AccountEntity( + id: 'account', + provider: BusyProvider.google, + authority: 'https://accounts.google.com', + providerAccountId: 'person@example.com', + authState: accountAuthStateSignedIn, + displayName: 'Personal', + ), +]; + +const _sources = [ + CalendarSourceEntity( + id: 'calendar', + accountId: 'account', + provider: BusyProvider.google, + providerCalendarId: 'calendar', + summary: 'Work calendar', + selected: true, + hidden: false, + readOnly: false, + isDeleted: false, + ), + CalendarSourceEntity( + id: 'deleted-calendar', + accountId: 'account', + provider: BusyProvider.google, + providerCalendarId: 'deleted-calendar', + summary: 'Deleted calendar', + selected: true, + hidden: false, + readOnly: false, + isDeleted: true, + ), +]; + +const _taskLists = [ + TaskListEntity( + accountId: 'account', + id: 'tasks', + title: 'Work tasks', + localDirty: false, + pendingDelete: false, + rawJson: '{}', + ), + TaskListEntity( + accountId: 'account', + id: 'deleted-tasks', + title: 'Deleted task list', + localDirty: false, + pendingDelete: true, + rawJson: '{}', + ), +]; diff --git a/test/features/schedule/presentation/schedule_search_platform_test.dart b/test/features/schedule/presentation/schedule_search_platform_test.dart index 63ed8bbc..b14c3cd1 100644 --- a/test/features/schedule/presentation/schedule_search_platform_test.dart +++ b/test/features/schedule/presentation/schedule_search_platform_test.dart @@ -6,14 +6,15 @@ import 'package:busymax/src/calendar_providers/calendar_sync_dto.dart'; import 'package:busymax/src/db/app_database.dart'; import 'package:busymax/src/features/accounts/data/accounts_repository.dart'; import 'package:busymax/src/features/calendar/data/calendar_repository.dart'; +import 'package:busymax/src/features/schedule/presentation/schedule_agenda_view.dart'; import 'package:busymax/src/features/schedule/presentation/schedule_month_view.dart'; +import 'package:busymax/src/features/schedule/presentation/schedule_search_filters.dart'; import 'package:busymax/src/features/schedule/presentation/schedule_sidebar.dart'; import 'package:busymax/src/features/schedule/presentation/schedule_workspace.dart'; import 'package:busymax/src/features/task_lists/data/task_lists_repository.dart'; import 'package:busymax/src/features/tasks/data/tasks_repository.dart'; import 'package:busymax/src/platform/linux_header_bar_provider.dart'; import 'package:busymax/src/platform/linux_header_bar_service.dart'; -import 'package:busymax/src/platform/linux_schedule_search_filter_service.dart'; import 'package:busymax/src/providers/busy_provider.dart'; import 'package:busymax/src/schedule/schedule_filters.dart'; import 'package:busymax/src/schedule/schedule_repository.dart'; @@ -25,6 +26,7 @@ import 'package:busymax/src/ui/windows/windows_schedule_source_pane.dart'; import 'package:drift/drift.dart' hide Column, isNull, isNotNull; import 'package:fluent_ui/fluent_ui.dart' as fluent; import 'package:flutter/material.dart'; +import 'package:yaru/yaru.dart'; import 'package:busymax/src/android/presentation/android_schedule_search_filters.dart'; import 'package:busymax/src/features/connectivity/network_connectivity_service.dart'; import 'package:flutter/services.dart'; @@ -48,21 +50,9 @@ void main() { await _searchShortcut(tester); expect(normal, findsNothing); final panelFinder = platform == 'linux' - ? find.byKey(const ValueKey('linux-native-search-filter-spacer')) + ? find.byType(ScheduleSearchFilters) : find.byType(WindowsScheduleSearchPane); expect(panelFinder, findsOneWidget); - if (platform == 'linux') { - expect(find.byType(Dialog), findsNothing); - expect( - fixture.filterCalls - .lastWhere((call) => call.method == 'setState') - .arguments, - allOf( - containsPair('active', true), - containsPair('sidebarVisible', true), - ), - ); - } final list = find.byKey( ValueKey( platform == 'linux' @@ -74,14 +64,24 @@ void main() { expect(find.text('Far future review'), findsOneWidget); expect(find.text('No due task'), findsOneWidget); if (platform == 'linux') { - expect(_nativeCriteria(fixture.filterCalls), isNotNull); + expect(find.byType(BusyMaxSidebarSurface), findsOneWidget); + expect( + find.byKey(const ValueKey('linux-native-search-filter-spacer')), + findsNothing, + ); + expect( + tester + .widget(find.byType(ScheduleAgendaView)) + .searchCriteria, + isNotNull, + ); } ScheduleSearchCriteria criteria() => platform == 'linux' - ? _nativeCriteria(fixture.filterCalls) + ? tester.widget(panelFinder).value : tester.widget(panelFinder).value; - Future change(ScheduleSearchCriteria value) async { + void change(ScheduleSearchCriteria value) { if (platform == 'linux') { - await _applyLinuxCriteria(fixture.filters, criteria(), value); + tester.widget(panelFinder).onChanged(value); } else { tester .widget(panelFinder) @@ -90,7 +90,7 @@ void main() { } final initial = criteria(); - await change( + change( criteria().copyWith( type: ScheduleSearchType.tasks, taskCompletion: ScheduleTaskCompletion.open, @@ -102,27 +102,23 @@ void main() { expect(find.text('Completed task'), findsNothing); expect(find.text('No due task'), findsNothing); expect(find.text('Far future review'), findsNothing); - // Exercise the actual source checkbox, then verify repository/settings stayed intact. + // Exercise the actual source switch, then verify repository/settings + // stayed intact. final checkbox = platform == 'linux' - ? null + ? find + .descendant( + of: panelFinder, + matching: find.byType(YaruSwitchListTile), + ) + .first : find .descendant( of: panelFinder, matching: find.byType(fluent.Checkbox), ) .first; - if (platform == 'linux') { - await fixture.filters.handleNativeMethodCall( - const MethodCall('taskListToggled', { - 'accountId': 'account', - 'taskListId': 'list', - 'selected': false, - }), - ); - } else { - await tester.ensureVisible(checkbox!); - await tester.tap(checkbox); - } + await tester.ensureVisible(checkbox); + await tester.tap(checkbox); await tester.pumpAndSettle(); expect(criteria().taskListKeys, isEmpty); expect(find.text('Overdue task'), findsNothing); @@ -138,33 +134,27 @@ void main() { isTrue, ); if (platform == 'linux') { - await fixture.filters.handleNativeMethodCall( - const MethodCall('clearFilters'), - ); + tester.widget(panelFinder).onClear(); } else { tester.widget(panelFinder).onClear(); } await tester.pumpAndSettle(); expect(criteria(), initial); final calendarToggle = platform == 'linux' - ? null + ? find + .descendant( + of: panelFinder, + matching: find.byType(YaruSwitchListTile), + ) + .first : find .descendant( of: panelFinder, matching: find.byType(fluent.Checkbox), ) .first; - if (platform == 'linux') { - await fixture.filters.handleNativeMethodCall( - MethodCall('calendarSourceToggled', { - 'sourceId': criteria().sourceIds.single, - 'selected': false, - }), - ); - } else { - await tester.ensureVisible(calendarToggle!); - await tester.tap(calendarToggle); - } + await tester.ensureVisible(calendarToggle); + await tester.tap(calendarToggle); await tester.pumpAndSettle(); expect(criteria().sourceIds, isEmpty); expect( @@ -173,19 +163,10 @@ void main() { isTrue, ); expect(find.text('Far future review'), findsNothing); - if (platform == 'linux') { - await fixture.filters.handleNativeMethodCall( - MethodCall('calendarSourceToggled', { - 'sourceId': initial.sourceIds.single, - 'selected': true, - }), - ); - } else { - await tester.tap(calendarToggle!); - } + await tester.tap(calendarToggle); await tester.pumpAndSettle(); - await change( + change( criteria().copyWith( type: ScheduleSearchType.events, person: 'alex@example.com', @@ -193,7 +174,7 @@ void main() { ); await tester.pumpAndSettle(); expect(find.text('Far future review'), findsOneWidget); - await change( + change( criteria().copyWith( date: ScheduleSearchDate.custom, customStart: DateTime(2040, 2, 15), @@ -203,7 +184,7 @@ void main() { ); await tester.pumpAndSettle(); expect(find.text('Far future review'), findsOneWidget); - await change( + change( criteria().copyWith( customStart: DateTime(2040, 2, 16), customEnd: DateTime(2040, 2, 16), @@ -211,7 +192,7 @@ void main() { ); await tester.pumpAndSettle(); expect(find.text('Far future review'), findsNothing); - await change(criteria().copyWith(date: ScheduleSearchDate.any)); + change(criteria().copyWith(date: ScheduleSearchDate.any)); await tester.pumpAndSettle(); // Clearing only the entry retains Person and the temporary scope. final entry = platform == 'linux' @@ -245,14 +226,6 @@ void main() { await tester.pumpAndSettle(); expect(normal, findsOneWidget); expect(panelFinder, findsNothing); - if (platform == 'linux') { - expect( - fixture.filterCalls - .lastWhere((call) => call.method == 'setState') - .arguments, - containsPair('active', false), - ); - } expect( platform == 'linux' ? find.byType(ScheduleMonthView) @@ -271,12 +244,12 @@ void main() { testWidgets( '$platform narrow search opens full filters through sidebar action', (tester) async { - final fixture = await _mount(tester, platform, width: 650); + await _mount(tester, platform, width: 650); await tester.pumpAndSettle(); await _searchShortcut(tester); expect( platform == 'linux' - ? find.byKey(const ValueKey('linux-native-search-filter-spacer')) + ? find.byType(ScheduleSearchFilters) : find.byType(WindowsScheduleSearchPane), findsNothing, ); @@ -284,15 +257,40 @@ void main() { await tester.pumpAndSettle(); expect( platform == 'linux' - ? find.byType(Dialog) + ? find.byType(ScheduleSearchFilters) : find.byType(WindowsScheduleSearchPane), - platform == 'linux' ? findsNothing : findsOneWidget, + findsOneWidget, ); if (platform == 'linux') { + expect(find.byType(BusyMaxDialogShell), findsOneWidget); + expect(find.byType(BusyMaxSidebarSurface), findsNothing); + expect( + tester + .widget( + find.byType(ScheduleSearchFilters), + ) + .sidebar, + isFalse, + ); + Navigator.of( + tester.element(find.byType(ScheduleSearchFilters)), + ).pop(); + await tester.pumpAndSettle(); + expect(find.byType(BusyMaxDialogShell), findsNothing); + expect(find.byType(ScheduleSearchFilters), findsNothing); expect( - fixture.filterCalls.where((call) => call.method == 'showModal'), - isNotEmpty, + find.byKey(const ValueKey('schedule-search-results')), + findsOneWidget, ); + + await tester.sendKeyEvent(LogicalKeyboardKey.f9); + await tester.pumpAndSettle(); + expect(find.byType(BusyMaxDialogShell), findsOneWidget); + tester.view.physicalSize = const Size(1280, 900); + await tester.pumpAndSettle(); + expect(find.byType(BusyMaxDialogShell), findsNothing); + expect(find.byType(ScheduleSearchFilters), findsOneWidget); + expect(find.byType(BusyMaxSidebarSurface), findsOneWidget); } expect( find.text('Custom range'), @@ -407,15 +405,11 @@ Future _searchShortcut(WidgetTester tester) async { await tester.pumpAndSettle(); } -Future< - ({ - AppDatabase db, - ProviderContainer container, - LinuxScheduleSearchFilterService filters, - List filterCalls, - }) -> -_mount(WidgetTester tester, String platform, {double width = 1280}) async { +Future<({AppDatabase db, ProviderContainer container})> _mount( + WidgetTester tester, + String platform, { + double width = 1280, +}) async { tester.view ..physicalSize = Size(width, 900) ..devicePixelRatio = 1; @@ -493,22 +487,6 @@ _mount(WidgetTester tester, String platform, {double width = 1280}) async { ); } final header = LinuxHeaderBarService(isLinux: false); - const filterChannel = MethodChannel( - 'busymax_test/schedule_search_platform_filters', - ); - final filterCalls = []; - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(filterChannel, (call) async { - filterCalls.add(call); - if (call.method == 'initialize' || call.method == 'showModal') { - return true; - } - return null; - }); - final filters = LinuxScheduleSearchFilterService( - channel: filterChannel, - isLinux: platform == 'linux', - ); final container = ProviderContainer( overrides: [ networkAvailabilityProvider.overrideWith( @@ -517,7 +495,6 @@ _mount(WidgetTester tester, String platform, {double width = 1280}) async { databaseProvider.overrideWithValue(db), localTimeZoneProvider.overrideWithValue('UTC'), linuxHeaderBarServiceProvider.overrideWithValue(header), - linuxScheduleSearchFilterServiceProvider.overrideWithValue(filters), localSettingsStoreProvider.overrideWithValue(MemorySettingsStore()), initialAppSettingsProvider.overrideWithValue( AppSettings.defaults().copyWith( @@ -542,9 +519,6 @@ _mount(WidgetTester tester, String platform, {double width = 1280}) async { await tester.pumpWidget(const SizedBox.shrink()); container.dispose(); header.dispose(); - filters.dispose(); - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(filterChannel, null); await db.close(); }); await tester.pumpWidget( @@ -563,79 +537,5 @@ _mount(WidgetTester tester, String platform, {double width = 1280}) async { ), ), ); - return ( - db: db, - container: container, - filters: filters, - filterCalls: filterCalls, - ); -} - -Future _applyLinuxCriteria( - LinuxScheduleSearchFilterService service, - ScheduleSearchCriteria current, - ScheduleSearchCriteria next, -) async { - final calls = [ - if (current.type != next.type) MethodCall('typeChanged', next.type.name), - if (current.date != next.date) MethodCall('dateChanged', next.date.name), - if (current.taskCompletion != next.taskCompletion) - MethodCall('taskCompletionChanged', next.taskCompletion.name), - if (current.taskDueState != next.taskDueState) - MethodCall('taskDueStateChanged', next.taskDueState.name), - if (current.person != next.person) MethodCall('personChanged', next.person), - if (current.location != next.location) - MethodCall('locationChanged', next.location), - if (current.customStart != next.customStart && next.customStart != null) - MethodCall( - 'customStartChanged', - next.customStart!.toIso8601String().substring(0, 10), - ), - if (current.customEnd != next.customEnd && next.customEnd != null) - MethodCall( - 'customEndChanged', - next.customEnd!.toIso8601String().substring(0, 10), - ), - ]; - for (final call in calls) { - await service.handleNativeMethodCall(call); - } -} - -ScheduleSearchCriteria _nativeCriteria(List calls) { - final state = - calls.lastWhere((call) => call.method == 'setState').arguments - as Map; - final value = state['criteria']! as Map; - DateTime? date(String key) { - final encoded = value[key] as String?; - return encoded == null ? null : DateTime.parse(encoded); - } - - return ScheduleSearchCriteria( - type: ScheduleSearchType.values.byName(value['type']! as String), - date: ScheduleSearchDate.values.byName(value['date']! as String), - taskCompletion: ScheduleTaskCompletion.values.byName( - value['taskCompletion']! as String, - ), - taskDueState: ScheduleTaskDueState.values.byName( - value['taskDueState']! as String, - ), - person: value['person']! as String, - location: value['location']! as String, - customStart: date('customStart'), - customEnd: date('customEnd'), - referenceDate: date('referenceDate')!, - firstWeekday: value['firstWeekday']! as int, - sourceIds: { - for (final id in value['sourceIds']! as List) id! as String, - }, - taskListKeys: { - for (final item in value['taskListKeys']! as List) - ScheduleTaskListKey( - accountId: (item! as Map)['accountId']! as String, - taskListId: (item as Map)['taskListId']! as String, - ), - }, - ); + return (db: db, container: container); } diff --git a/test/features/schedule/presentation/schedule_workspace_states_test.dart b/test/features/schedule/presentation/schedule_workspace_states_test.dart index 5e9f666f..a3c52f8b 100644 --- a/test/features/schedule/presentation/schedule_workspace_states_test.dart +++ b/test/features/schedule/presentation/schedule_workspace_states_test.dart @@ -8,10 +8,10 @@ import 'package:busymax/src/db/app_database.dart'; import 'package:busymax/src/features/accounts/data/accounts_repository.dart'; import 'package:busymax/src/features/schedule/presentation/schedule_empty_states.dart'; import 'package:busymax/src/features/schedule/presentation/schedule_sidebar.dart'; +import 'package:busymax/src/features/schedule/presentation/schedule_search_filters.dart'; import 'package:busymax/src/features/schedule/presentation/schedule_workspace.dart'; import 'package:busymax/src/platform/linux_header_bar_service.dart'; import 'package:busymax/src/platform/linux_header_bar_provider.dart'; -import 'package:busymax/src/platform/linux_schedule_search_filter_service.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter/services.dart'; @@ -255,29 +255,11 @@ void main() { channel: channel, isLinux: true, ); - const filterChannel = MethodChannel( - 'busymax_test/schedule_native_filters', - ); - final filterCalls = []; - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(filterChannel, (call) async { - filterCalls.add(call); - return call.method == 'initialize' ? true : null; - }); - addTearDown(() { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(filterChannel, null); - }); - final filterService = LinuxScheduleSearchFilterService( - channel: filterChannel, - isLinux: true, - ); await _pumpWorkspace( tester, accountsFactory: () => Stream.value(const []), headerBarService: headerBarService, - searchFilterService: filterService, ); await tester.pumpAndSettle(); @@ -289,6 +271,14 @@ void main() { calls.where((call) => call.method == 'setState').last.arguments, containsPair('searchActive', true), ); + expect(find.byType(ScheduleSearchFilters), findsOneWidget); + + await tester.sendKeyEvent(LogicalKeyboardKey.f9); + await tester.pumpAndSettle(); + expect(find.byType(ScheduleSearchFilters), findsNothing); + await tester.sendKeyEvent(LogicalKeyboardKey.f9); + await tester.pumpAndSettle(); + expect(find.byType(ScheduleSearchFilters), findsOneWidget); await headerBarService.handleNativeMethodCall( const MethodCall('searchQueryChanged', 'planning'), @@ -299,23 +289,17 @@ void main() { containsPair('searchQuery', 'planning'), ); - expect( - find.byKey(const ValueKey('linux-native-search-filter-spacer')), - findsOneWidget, - ); - expect(find.byType(TextField), findsNothing); - await filterService.handleNativeMethodCall( - const MethodCall('personChanged', 'Taylor'), - ); + await tester.tap(find.byType(TextField).first); + await tester.pumpAndSettle(); + expect(calls.where((call) => call.method == 'focusContent'), isNotEmpty); + await tester.enterText(find.byType(TextField).first, 'Taylor'); await tester.pumpAndSettle(); - final nativeFilterState = - filterCalls.where((call) => call.method == 'setState').last.arguments - as Map; - expect(nativeFilterState['criteria'], containsPair('person', 'Taylor')); - expect(calls.where((call) => call.method == 'focusContent'), isEmpty); expect( - filterCalls.where((call) => call.method == 'setState').last.arguments, - containsPair('active', true), + tester + .widget(find.byType(ScheduleSearchFilters)) + .value + .person, + 'Taylor', ); expect( calls.where((call) => call.method == 'setState').last.arguments, @@ -357,10 +341,7 @@ void main() { await tester.sendKeyEvent(LogicalKeyboardKey.keyF); await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); await tester.pumpAndSettle(); - expect( - find.byKey(const ValueKey('linux-native-search-filter-spacer')), - findsOneWidget, - ); + expect(find.byType(ScheduleSearchFilters), findsOneWidget); await tester.sendKeyEvent(LogicalKeyboardKey.escape); await tester.pumpAndSettle(); expect(find.byType(ScheduleSidebar), findsOneWidget); @@ -389,16 +370,12 @@ Future _pumpWorkspace( WidgetTester tester, { required Stream> Function() accountsFactory, LinuxHeaderBarService? headerBarService, - LinuxScheduleSearchFilterService? searchFilterService, }) async { final database = AppDatabase.memoryForTests(); addTearDown(database.close); final resolvedHeaderBarService = headerBarService ?? LinuxHeaderBarService(isLinux: false); addTearDown(resolvedHeaderBarService.dispose); - final resolvedSearchFilterService = - searchFilterService ?? LinuxScheduleSearchFilterService(isLinux: false); - addTearDown(resolvedSearchFilterService.dispose); await tester.pumpWidget( ProviderScope( @@ -410,9 +387,6 @@ Future _pumpWorkspace( linuxHeaderBarServiceProvider.overrideWithValue( resolvedHeaderBarService, ), - linuxScheduleSearchFilterServiceProvider.overrideWithValue( - resolvedSearchFilterService, - ), ], child: localizedTestApp(child: const ScheduleWorkspace()), ), diff --git a/test/platform/linux_schedule_search_filter_service_test.dart b/test/platform/linux_schedule_search_filter_service_test.dart deleted file mode 100644 index 84caa724..00000000 --- a/test/platform/linux_schedule_search_filter_service_test.dart +++ /dev/null @@ -1,272 +0,0 @@ -import 'package:busymax/src/platform/linux_schedule_search_filter_service.dart'; -import 'package:busymax/src/schedule/schedule_filters.dart'; -import 'package:busymax/src/schedule/schedule_search_criteria.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_test/flutter_test.dart'; - -void main() { - TestWidgetsFlutterBinding.ensureInitialized(); - - const channel = MethodChannel('busymax_test/native_schedule_filters'); - - tearDown(() { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, null); - }); - - test('serializes complete localized presentation state', () async { - final calls = []; - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, (call) async { - calls.add(call); - return call.method == 'initialize' ? true : null; - }); - final service = LinuxScheduleSearchFilterService( - channel: channel, - isLinux: true, - ); - addTearDown(service.dispose); - final session = service.claimSession(); - addTearDown(session.dispose); - - final criteria = ScheduleSearchCriteria( - type: ScheduleSearchType.tasks, - date: ScheduleSearchDate.custom, - taskCompletion: ScheduleTaskCompletion.completed, - taskDueState: ScheduleTaskDueState.noDueDate, - person: 'Taylor', - location: 'Office', - customStart: DateTime(2026, 9, 12), - customEnd: DateTime(2026, 9, 15), - referenceDate: DateTime(2026, 9, 10), - firstWeekday: DateTime.monday, - sourceIds: {'calendar-b'}, - taskListKeys: { - const ScheduleTaskListKey(accountId: 'account-a', taskListId: 'list-b'), - }, - ); - await session.updateState( - LinuxScheduleSearchFilterState( - active: true, - sidebarVisible: true, - sidebarWidth: 300, - criteria: criteria, - labels: _labels, - accounts: const [ - LinuxScheduleSearchFilterAccount(id: 'account-a', label: 'Alex'), - ], - calendarSources: const [ - LinuxScheduleSearchFilterCalendarSource( - id: 'calendar-b', - accountId: 'account-a', - title: 'Work', - selected: true, - ), - ], - taskLists: const [ - LinuxScheduleSearchFilterTaskList( - accountId: 'account-a', - taskListId: 'list-b', - title: 'Tasks', - selected: true, - ), - ], - ), - ); - - final state = - calls.singleWhere((call) => call.method == 'setState').arguments - as Map; - expect(state, containsPair('schemaVersion', 1)); - expect(state, containsPair('active', true)); - expect(state['labels'], containsPair('searchFilters', 'Search filters')); - expect(state['labels'], containsPair('noDueDate', 'No due date')); - expect( - state['calendarSources'], - contains(containsPair('id', 'calendar-b')), - ); - expect( - state['taskLists'], - contains( - allOf( - containsPair('accountId', 'account-a'), - containsPair('taskListId', 'list-b'), - ), - ), - ); - final serializedCriteria = state['criteria'] as Map; - expect(serializedCriteria, containsPair('type', 'tasks')); - expect(serializedCriteria, containsPair('date', 'custom')); - expect(serializedCriteria, containsPair('taskCompletion', 'completed')); - expect(serializedCriteria, containsPair('taskDueState', 'noDueDate')); - expect(serializedCriteria, containsPair('customStart', '2026-09-12')); - expect(serializedCriteria, containsPair('customEnd', '2026-09-15')); - expect( - serializedCriteria['taskListKeys'], - contains( - allOf( - containsPair('accountId', 'account-a'), - containsPair('taskListId', 'list-b'), - ), - ), - ); - }); - - test( - 'dispatches native criteria, source, clear, and dismiss events', - () async { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler( - channel, - (call) async => call.method == 'initialize' ? true : null, - ); - final service = LinuxScheduleSearchFilterService( - channel: channel, - isLinux: true, - ); - addTearDown(service.dispose); - final session = service.claimSession(); - addTearDown(session.dispose); - await session.initialize(); - final events = []; - final subscription = session.events.listen(events.add); - addTearDown(subscription.cancel); - - for (final call in const [ - MethodCall('typeChanged', 'events'), - MethodCall('dateChanged', 'tomorrow'), - MethodCall('taskCompletionChanged', 'open'), - MethodCall('taskDueStateChanged', 'overdue'), - MethodCall('personChanged', 'Taylor'), - MethodCall('locationChanged', 'Office'), - MethodCall('customStartChanged', '2026-09-12'), - MethodCall('customEndChanged', '2026-09-15'), - MethodCall('calendarSourceToggled', { - 'sourceId': 'calendar-b', - 'selected': false, - }), - MethodCall('taskListToggled', { - 'accountId': 'account-a', - 'taskListId': 'list-b', - 'selected': true, - }), - MethodCall('clearFilters'), - MethodCall('dismissSearch'), - ]) { - await service.handleNativeMethodCall(call); - } - await Future.delayed(Duration.zero); - - expect(events[0], isA()); - expect(events[1], isA()); - expect(events[2], isA()); - expect(events[3], isA()); - expect((events[4] as LinuxScheduleSearchPersonChanged).value, 'Taylor'); - expect((events[5] as LinuxScheduleSearchLocationChanged).value, 'Office'); - expect( - (events[6] as LinuxScheduleSearchCustomStartChanged).value, - DateTime(2026, 9, 12), - ); - expect( - (events[7] as LinuxScheduleSearchCustomEndChanged).value, - DateTime(2026, 9, 15), - ); - final source = events[8] as LinuxScheduleSearchCalendarSourceToggled; - expect(source.sourceId, 'calendar-b'); - expect(source.selected, isFalse); - final taskList = events[9] as LinuxScheduleSearchTaskListToggled; - expect(taskList.key.accountId, 'account-a'); - expect(taskList.key.taskListId, 'list-b'); - expect(taskList.selected, isTrue); - expect(events[10], isA()); - expect(events[11], isA()); - }, - ); - - test('only the current, available session receives native events', () async { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler( - channel, - (call) async => call.method == 'initialize' ? true : null, - ); - final service = LinuxScheduleSearchFilterService( - channel: channel, - isLinux: true, - ); - addTearDown(service.dispose); - final stale = service.claimSession(); - final current = service.claimSession(); - final staleEvents = []; - final currentEvents = []; - stale.events.listen(staleEvents.add); - current.events.listen(currentEvents.add); - await current.initialize(); - - await service.handleNativeMethodCall( - const MethodCall('personChanged', 'Current'), - ); - await Future.delayed(Duration.zero); - expect(staleEvents, isEmpty); - expect(currentEvents, hasLength(1)); - - current.dispose(); - stale.dispose(); - await service.handleNativeMethodCall( - const MethodCall('locationChanged', 'Ignored'), - ); - await Future.delayed(Duration.zero); - expect(staleEvents, isEmpty); - expect(currentEvents, hasLength(1)); - }); - - test('an unavailable bridge cannot dispatch into its session', () async { - final service = LinuxScheduleSearchFilterService( - channel: channel, - isLinux: false, - ); - addTearDown(service.dispose); - final session = service.claimSession(); - final events = []; - session.events.listen(events.add); - await session.initialize(); - - await service.handleNativeMethodCall( - const MethodCall('personChanged', 'Ignored'), - ); - await Future.delayed(Duration.zero); - - expect(service.isAvailable, isFalse); - expect(events, isEmpty); - }); -} - -const _labels = LinuxScheduleSearchFilterLabels( - searchFilters: 'Search filters', - type: 'Type', - all: 'All', - events: 'Events', - tasks: 'Tasks', - date: 'Date', - anyDate: 'Any date', - today: 'Today', - tomorrow: 'Tomorrow', - thisWeek: 'This week', - customRange: 'Custom range', - startDate: 'Start date', - endDate: 'End date', - taskStatus: 'Task status', - open: 'Open', - completed: 'Completed', - taskDue: 'Task due', - anyDueState: 'Any due state', - overdue: 'Overdue', - noDueDate: 'No due date', - person: 'Person', - location: 'Location', - sources: 'Sources', - clearFilters: 'Clear filters', - noSources: 'No search sources selected', - close: 'Close', - cancel: 'Cancel', - ok: 'OK', -); From 415154b24dbceb98acff6697612d4df16c98dcac Mon Sep 17 00:00:00 2001 From: ChenZhenIO Date: Tue, 15 Sep 2026 18:22:55 -0700 Subject: [PATCH 12/25] Refactor Schedule search result handling and agenda view rendering --- .../presentation/android_schedule_screen.dart | 30 ++- .../presentation/schedule_agenda_view.dart | 160 +++++++++----- .../schedule_search_result_text.dart | 44 ++++ lib/src/ui/windows/windows_schedule_page.dart | 21 +- .../schedule_search_platform_test.dart | 9 + .../schedule_search_result_text_test.dart | 39 ++++ .../presentation/schedule_views_test.dart | 200 ++++++++++++++++++ 7 files changed, 430 insertions(+), 73 deletions(-) diff --git a/lib/src/android/presentation/android_schedule_screen.dart b/lib/src/android/presentation/android_schedule_screen.dart index 87e9aecd..1db18ee8 100644 --- a/lib/src/android/presentation/android_schedule_screen.dart +++ b/lib/src/android/presentation/android_schedule_screen.dart @@ -884,35 +884,47 @@ class _AgendaList extends StatelessWidget { if (items.isEmpty) { return Center(child: Text(context.l10n.noEventsOrTasks)); } + final agendaItems = searchCriteria == null + ? items + : (List.of(items) + ..sort(compareScheduleSearchResultPresentation)); return ListView.separated( padding: const EdgeInsets.fromLTRB(12, 8, 12, 96), - itemCount: items.length, + itemCount: agendaItems.length, separatorBuilder: (_, _) => const SizedBox(height: 6), itemBuilder: (context, index) { - final item = items[index]; + final item = agendaItems[index]; + final displayDate = searchCriteria == null + ? item.start + : scheduleSearchResultDisplayDate(item); final time = item.start == null ? context.l10n.noDate : item.allDay ? context.l10n.allDay : BusyMaxTimeFormatScope.of(context).format(item.start!); final isToday = - item.start != null && _sameDay(item.start!, DateTime.now()); + displayDate != null && _sameDay(displayDate, DateTime.now()); + final previousDisplayDate = index == 0 + ? null + : searchCriteria == null + ? agendaItems[index - 1].start + : scheduleSearchResultDisplayDate(agendaItems[index - 1]); return Card( child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ if (index == 0 || - (item.start == null && items[index - 1].start != null) || - (item.start != null && - (items[index - 1].start == null || - !_sameDay(item.start!, items[index - 1].start!)))) + (displayDate == null && previousDisplayDate != null) || + (displayDate != null && + (previousDisplayDate == null || + !_sameDay(displayDate, previousDisplayDate)))) Padding( padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), child: Text( - item.start == null + displayDate == null ? context.l10n.noDate : '${isToday ? '${context.l10n.today} · ' : ''}' - '${DateFormat.yMMMMEEEEd().format(item.start!)}', + '${DateFormat.yMMMMEEEEd().format(displayDate)}', style: Theme.of(context).textTheme.labelLarge?.copyWith( color: isToday ? Theme.of(context).colorScheme.primary diff --git a/lib/src/features/schedule/presentation/schedule_agenda_view.dart b/lib/src/features/schedule/presentation/schedule_agenda_view.dart index 926e60e3..32bb2c76 100644 --- a/lib/src/features/schedule/presentation/schedule_agenda_view.dart +++ b/lib/src/features/schedule/presentation/schedule_agenda_view.dart @@ -88,14 +88,38 @@ class _ScheduleAgendaViewState extends State { @override Widget build(BuildContext context) { - if (widget.searchCriteria != null) { - return ListView.builder( - key: const ValueKey('schedule-search-results'), - padding: const EdgeInsetsDirectional.all(BusyMaxSpacing.md), - itemCount: widget.items.length, - itemBuilder: (context, index) => _standaloneRow(widget.items[index]), - ); - } + final sections = widget.searchCriteria == null + ? _ordinarySections(context) + : _searchSections(context); + return NotificationListener( + onNotification: _handleScroll, + child: ColoredBox( + color: BusyMaxSurfaceColors.of(context).window, + child: ListView( + key: widget.searchCriteria == null + ? null + : const ValueKey('schedule-search-results'), + padding: const EdgeInsets.fromLTRB( + BusyMaxSpacing.lg, + BusyMaxSpacing.md, + BusyMaxSpacing.lg, + BusyMaxSpacing.xl, + ), + children: [ + for (final section in sections) + if (section.children.isNotEmpty) + BusyMaxGroupedList( + title: section.title, + filled: true, + children: section.children, + ), + ], + ), + ), + ); + } + + List<_AgendaSection> _ordinarySections(BuildContext context) { final dated = widget.items.where((item) => item.start != null).toList(); final noDateTasks = ScheduleProjection.noDateTasks(widget.items); final groups = ScheduleProjection.groupByDay(dated); @@ -127,58 +151,68 @@ class _ScheduleAgendaViewState extends State { for (final day in days) day: _entriesFor(groups[day]!, hierarchy, emittedTasks), }; - - return NotificationListener( - onNotification: _handleScroll, - child: ColoredBox( - color: BusyMaxSurfaceColors.of(context).window, - child: ListView( - padding: const EdgeInsets.fromLTRB( - BusyMaxSpacing.lg, - BusyMaxSpacing.md, - BusyMaxSpacing.lg, - BusyMaxSpacing.xl, - ), + return [ + if (overdueEntries.isNotEmpty || widget.hasMoreOverdueTasks) + _AgendaSection( + title: context.l10n.overdue, children: [ - if (overdueEntries.isNotEmpty || widget.hasMoreOverdueTasks) - BusyMaxGroupedList( - title: context.l10n.overdue, - filled: true, - children: [ - ...overdueEntries, - if (widget.hasMoreOverdueTasks && - widget.onLoadMoreOverdue != null) - _AgendaLoadMoreRow( - title: context.l10n.agendaLoadMoreOverdue, - onTap: widget.onLoadMoreOverdue!, - ), - ], + ...overdueEntries, + if (widget.hasMoreOverdueTasks && widget.onLoadMoreOverdue != null) + _AgendaLoadMoreRow( + title: context.l10n.agendaLoadMoreOverdue, + onTap: widget.onLoadMoreOverdue!, ), - if (noDateEntries.isNotEmpty || widget.hasMoreNoDateTasks) - BusyMaxGroupedList( - title: context.l10n.noDate, - filled: true, - children: [ - ...noDateEntries, - if (widget.hasMoreNoDateTasks && - widget.onLoadMoreNoDate != null) - _AgendaLoadMoreRow( - title: context.l10n.agendaLoadMoreNoDate, - onTap: widget.onLoadMoreNoDate!, - ), - ], + ], + ), + if (noDateEntries.isNotEmpty || widget.hasMoreNoDateTasks) + _AgendaSection( + title: context.l10n.noDate, + children: [ + ...noDateEntries, + if (widget.hasMoreNoDateTasks && widget.onLoadMoreNoDate != null) + _AgendaLoadMoreRow( + title: context.l10n.agendaLoadMoreNoDate, + onTap: widget.onLoadMoreNoDate!, ), - for (final day in days) - if (dayEntries[day]!.isNotEmpty) - BusyMaxGroupedList( - title: _dayLabel(context, day), - filled: true, - children: dayEntries[day]!, - ), ], ), - ), - ); + for (final day in days) + _AgendaSection( + title: _dayLabel(context, day), + children: dayEntries[day]!, + ), + ]; + } + + List<_AgendaSection> _searchSections(BuildContext context) { + final orderedItems = List.of(widget.items) + ..sort(compareScheduleSearchResultPresentation); + final hierarchy = _AgendaHierarchyIndex(orderedItems); + final datedGroups = >{}; + final undated = []; + for (final item in orderedItems) { + final displayDate = scheduleSearchResultDisplayDate(item); + if (displayDate == null) { + undated.add(item); + } else { + datedGroups + .putIfAbsent(ScheduleProjection.day(displayDate), () => []) + .add(item); + } + } + final emittedTasks = {}; + return [ + for (final entry in datedGroups.entries) + _AgendaSection( + title: _dayLabel(context, entry.key), + children: _entriesFor(entry.value, hierarchy, emittedTasks), + ), + if (undated.isNotEmpty) + _AgendaSection( + title: context.l10n.noDate, + children: _entriesFor(undated, hierarchy, emittedTasks), + ), + ]; } List _entriesFor( @@ -340,6 +374,15 @@ class _ScheduleAgendaViewState extends State { title: task.title, completed: task.completed, subtitleBuilder: (context) { + final searchCriteria = widget.searchCriteria; + if (searchCriteria != null) { + return scheduleSearchResultText( + context, + task, + searchCriteria, + widget.searchQuery, + ); + } final values = [ _nestedTaskScheduleLabel(context, task, hierarchyRoot), if (showSource) ScheduleProjection.sourceLabelForScheduleItem(task), @@ -379,6 +422,13 @@ class _ScheduleAgendaViewState extends State { } } +class _AgendaSection { + const _AgendaSection({required this.title, required this.children}); + + final String title; + final List children; +} + class _AgendaLoadMoreRow extends StatelessWidget { const _AgendaLoadMoreRow({required this.title, required this.onTap}); diff --git a/lib/src/features/schedule/presentation/schedule_search_result_text.dart b/lib/src/features/schedule/presentation/schedule_search_result_text.dart index 529e87f6..98bbc685 100644 --- a/lib/src/features/schedule/presentation/schedule_search_result_text.dart +++ b/lib/src/features/schedule/presentation/schedule_search_result_text.dart @@ -7,6 +7,50 @@ import '../../../schedule/schedule_item.dart'; import '../../../schedule/schedule_search_criteria.dart'; import '../../../schedule/schedule_search_match.dart'; +/// The date that gives a search result its place in Agenda presentation. +/// +/// Search date filters intentionally use a task's actual due value, while the +/// ordinary Schedule projection uses its scheduling start. Keep that +/// distinction in presentation so an already-matched task remains under the +/// date that caused it to match. +DateTime? scheduleSearchResultDisplayDate(ScheduleItem item) => + item is TaskScheduleItem ? item.due ?? item.start : item.start; + +int compareScheduleSearchResultPresentation( + ScheduleItem first, + ScheduleItem second, +) { + final firstDate = scheduleSearchResultDisplayDate(first); + final secondDate = scheduleSearchResultDisplayDate(second); + final dayComparison = _searchResultDay( + firstDate, + ).compareTo(_searchResultDay(secondDate)); + if (dayComparison != 0) return dayComparison; + if (first.allDay != second.allDay) return first.allDay ? -1 : 1; + final timeComparison = _searchResultDate( + firstDate, + ).compareTo(_searchResultDate(secondDate)); + if (timeComparison != 0) return timeComparison; + if (first.kind != second.kind) { + return first.kind == ScheduleItemKind.calendarEvent ? -1 : 1; + } + final titleComparison = first.title.toLowerCase().compareTo( + second.title.toLowerCase(), + ); + if (titleComparison != 0) return titleComparison; + final accountComparison = first.accountId.compareTo(second.accountId); + if (accountComparison != 0) return accountComparison; + final sourceComparison = first.sourceId.compareTo(second.sourceId); + if (sourceComparison != 0) return sourceComparison; + return first.id.compareTo(second.id); +} + +DateTime _searchResultDay(DateTime? value) => value == null + ? DateTime(9999) + : DateTime(value.year, value.month, value.day); + +DateTime _searchResultDate(DateTime? value) => value ?? DateTime(9999); + String scheduleSearchResultText( BuildContext context, ScheduleItem item, diff --git a/lib/src/ui/windows/windows_schedule_page.dart b/lib/src/ui/windows/windows_schedule_page.dart index f7d84b06..d5fc207a 100644 --- a/lib/src/ui/windows/windows_schedule_page.dart +++ b/lib/src/ui/windows/windows_schedule_page.dart @@ -567,7 +567,6 @@ class _WindowsSchedulePageState extends ConsumerState { items: items, locale: locale, onOpen: _showItemDetails, - groupByDate: false, searchCriteria: _searchCriteria, searchQuery: _query, ); @@ -1655,7 +1654,6 @@ class _ScheduleModeView extends StatelessWidget { items: items, locale: locale, onOpen: onOpen, - groupByDate: true, onLoadMore: onLoadMoreAgenda, ), }; @@ -1669,7 +1667,6 @@ class _AgendaList extends StatelessWidget { required this.items, required this.locale, required this.onOpen, - required this.groupByDate, this.onLoadMore, }); @@ -1678,13 +1675,16 @@ class _AgendaList extends StatelessWidget { final List items; final String locale; final ValueChanged onOpen; - final bool groupByDate; final VoidCallback? onLoadMore; @override Widget build(BuildContext context) { + final agendaItems = searchCriteria == null + ? items + : (List.of(items) + ..sort(compareScheduleSearchResultPresentation)); final rows = []; - if (items.isEmpty) { + if (agendaItems.isEmpty) { rows.add( Padding( padding: const EdgeInsets.symmetric(vertical: 48), @@ -1696,9 +1696,12 @@ class _AgendaList extends StatelessWidget { } DateTime? previousDay; var noDateShown = false; - for (final item in items) { - final day = item.start == null ? null : _dateOnly(item.start!); - if (groupByDate && day != previousDay) { + for (final item in agendaItems) { + final displayDate = searchCriteria == null + ? item.start + : scheduleSearchResultDisplayDate(item); + final day = displayDate == null ? null : _dateOnly(displayDate); + if (day != previousDay) { rows.add( Padding( padding: const EdgeInsetsDirectional.fromSTEB(4, 14, 4, 6), @@ -1712,7 +1715,7 @@ class _AgendaList extends StatelessWidget { ); previousDay = day; noDateShown = day == null; - } else if (groupByDate && day == null && !noDateShown) { + } else if (day == null && !noDateShown) { rows.add( Padding( padding: const EdgeInsetsDirectional.fromSTEB(4, 14, 4, 6), diff --git a/test/features/schedule/presentation/schedule_search_platform_test.dart b/test/features/schedule/presentation/schedule_search_platform_test.dart index b14c3cd1..307fe9eb 100644 --- a/test/features/schedule/presentation/schedule_search_platform_test.dart +++ b/test/features/schedule/presentation/schedule_search_platform_test.dart @@ -32,6 +32,7 @@ import 'package:busymax/src/features/connectivity/network_connectivity_service.d import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:intl/intl.dart'; import '../../../support/memory_settings_store.dart'; import '../../../test_localized_app.dart'; @@ -63,6 +64,10 @@ void main() { expect(list, findsOneWidget); expect(find.text('Far future review'), findsOneWidget); expect(find.text('No due task'), findsOneWidget); + expect( + find.text(DateFormat.yMMMMEEEEd('en').format(DateTime(2040, 2, 15))), + findsOneWidget, + ); if (platform == 'linux') { expect(find.byType(BusyMaxSidebarSurface), findsOneWidget); expect( @@ -318,6 +323,10 @@ void main() { findsOneWidget, ); expect(find.text('Far future review'), findsOneWidget); + expect( + find.text(DateFormat.yMMMMEEEEd('en').format(DateTime(2040, 2, 15))), + findsOneWidget, + ); await tester.tap(find.byIcon(Icons.tune)); await tester.pumpAndSettle(); final panel = find.byType(AndroidScheduleSearchFilters); diff --git a/test/features/schedule/presentation/schedule_search_result_text_test.dart b/test/features/schedule/presentation/schedule_search_result_text_test.dart index 8fe2c298..d7f1ab9d 100644 --- a/test/features/schedule/presentation/schedule_search_result_text_test.dart +++ b/test/features/schedule/presentation/schedule_search_result_text_test.dart @@ -112,4 +112,43 @@ void main() { ); expect(find.textContaining('3:45 PM'), findsOneWidget); }); + + test('search Agenda display date prefers task due then start', () { + final due = DateTime(2026, 6, 12, 15, 45); + final start = DateTime(2026, 6, 20, 9); + + expect(scheduleSearchResultDisplayDate(task(due: due, start: start)), due); + expect(scheduleSearchResultDisplayDate(task(start: start)), start); + expect(scheduleSearchResultDisplayDate(task()), isNull); + }); + + test('search Agenda ordering is due-first and leaves undated items last', () { + TaskScheduleItem result(String id, {DateTime? due, DateTime? start}) => + TaskScheduleItem( + id: id, + accountId: 'account', + sourceId: 'list', + provider: BusyProvider.microsoft, + title: id, + completed: false, + allDay: true, + due: due, + start: start, + ); + final values = [ + result('undated'), + result( + 'later start but earlier due', + due: DateTime(2026, 6, 12), + start: DateTime(2026, 6, 20), + ), + result('start fallback', start: DateTime(2026, 6, 14)), + ]..sort(compareScheduleSearchResultPresentation); + + expect(values.map((item) => item.id), [ + 'later start but earlier due', + 'start fallback', + 'undated', + ]); + }); } diff --git a/test/features/schedule/presentation/schedule_views_test.dart b/test/features/schedule/presentation/schedule_views_test.dart index 193fcc03..e27e30ee 100644 --- a/test/features/schedule/presentation/schedule_views_test.dart +++ b/test/features/schedule/presentation/schedule_views_test.dart @@ -29,6 +29,7 @@ import 'package:busymax/src/features/maps/domain/geographic_point.dart'; import 'package:busymax/src/platform/gtk_font_service.dart'; import 'package:busymax/src/schedule/schedule_item.dart'; import 'package:busymax/src/schedule/schedule_range.dart'; +import 'package:busymax/src/schedule/schedule_search_criteria.dart'; import 'package:busymax/src/providers/busy_provider.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; @@ -2802,6 +2803,184 @@ void main() { ); }); + testWidgets( + 'search results reuse Agenda groups and use task due dates without an overdue bucket', + (tester) async { + final criteria = ScheduleSearchCriteria( + referenceDate: DateTime(2040, 6, 15), + firstWeekday: DateTime.monday, + sourceIds: const {'calendar'}, + taskListKeys: { + const ScheduleTaskListKey( + accountId: 'microsoft:m', + taskListId: 'tasks', + ), + }, + ); + final dueFirstTask = TaskScheduleItem( + id: 'due-first', + accountId: 'microsoft:m', + provider: BusyProvider.microsoft, + sourceId: 'tasks', + title: 'Due-first task', + completed: false, + allDay: true, + start: DateTime(2040, 6, 20), + due: DateTime(2040, 6, 12), + sourceName: 'Tasks', + ); + final startFallbackTask = TaskScheduleItem( + id: 'start-fallback', + accountId: 'microsoft:m', + provider: BusyProvider.microsoft, + sourceId: 'tasks', + title: 'Start fallback task', + completed: false, + allDay: true, + start: DateTime(2040, 6, 14), + sourceName: 'Tasks', + ); + const undatedTask = TaskScheduleItem( + id: 'undated', + accountId: 'microsoft:m', + provider: BusyProvider.microsoft, + sourceId: 'tasks', + title: 'Undated task', + completed: false, + allDay: true, + sourceName: 'Tasks', + ); + + await tester.pumpWidget( + localizedTestApp( + child: Scaffold( + body: ScheduleAgendaView( + range: ScheduleRange.week(criteria.referenceDate), + items: [undatedTask, startFallbackTask, dueFirstTask], + searchCriteria: criteria, + searchQuery: 'task', + onItemSelected: (_, _, [_]) {}, + onTaskCompletionChanged: (_, _) {}, + ), + ), + ), + ); + + expect( + find.byKey(const ValueKey('schedule-search-results')), + findsOneWidget, + ); + expect(find.byType(BusyMaxGroupedList), findsNWidgets(3)); + final dueGroup = tester.widget( + find.ancestor( + of: find.text('Due-first task'), + matching: find.byType(BusyMaxGroupedList), + ), + ); + final startGroup = tester.widget( + find.ancestor( + of: find.text('Start fallback task'), + matching: find.byType(BusyMaxGroupedList), + ), + ); + final undatedGroup = tester.widget( + find.ancestor( + of: find.text('Undated task'), + matching: find.byType(BusyMaxGroupedList), + ), + ); + expect( + dueGroup.title, + DateFormat.yMMMMEEEEd('en').format(DateTime(2040, 6, 12)), + ); + expect( + startGroup.title, + DateFormat.yMMMMEEEEd('en').format(DateTime(2040, 6, 14)), + ); + expect(undatedGroup.title, 'No date'); + expect(find.text('Overdue'), findsNothing); + expect( + tester.getTopLeft(find.text('Due-first task')).dy, + lessThan(tester.getTopLeft(find.text('Start fallback task')).dy), + ); + expect( + tester.getTopLeft(find.text('Start fallback task')).dy, + lessThan(tester.getTopLeft(find.text('Undated task')).dy), + ); + }, + ); + + testWidgets('search Agenda keeps matched task hierarchy and context', ( + tester, + ) async { + final day = DateTime(2040, 6, 12); + final criteria = ScheduleSearchCriteria( + referenceDate: day, + firstWeekday: DateTime.monday, + sourceIds: const {}, + taskListKeys: { + const ScheduleTaskListKey(accountId: 'google:g', taskListId: 'tasks'), + }, + ); + final parent = TaskScheduleItem( + id: 'parent', + accountId: 'google:g', + provider: BusyProvider.google, + sourceId: 'tasks', + title: 'Matching parent', + completed: false, + allDay: true, + start: day, + due: day, + hasSubtasks: true, + sourceName: 'Projects', + ); + final child = TaskScheduleItem( + id: 'child', + accountId: 'google:g', + provider: BusyProvider.google, + sourceId: 'tasks', + title: 'Matching child', + completed: false, + allDay: true, + start: day, + due: day, + notes: 'Needle appears in child notes', + parentId: 'parent', + parentTitle: 'Matching parent', + hierarchyDepth: 1, + sourceName: 'Projects', + ); + + await tester.pumpWidget( + localizedTestApp( + child: Scaffold( + body: ScheduleAgendaView( + range: ScheduleRange.day(day), + items: [child, parent], + searchCriteria: criteria, + searchQuery: 'needle', + onItemSelected: (_, _, [_]) {}, + onTaskCompletionChanged: (_, _) {}, + ), + ), + ), + ); + + final group = find.byKey( + const ValueKey('agenda-task-group-google:g-tasks-parent'), + ); + final childRow = find.byKey( + const ValueKey('agenda-subtask-google:g-tasks-child'), + ); + expect(group, findsOneWidget); + expect(find.descendant(of: group, matching: childRow), findsOneWidget); + expect( + find.textContaining('Needle appears in child notes'), + findsOneWidget, + ); + }); + testWidgets('agenda nests task children and Microsoft checklist steps', ( tester, ) async { @@ -3492,6 +3671,27 @@ void main() { expect(design, isNot(contains('class _BusyMaxRowTile'))); }); + test('search results use each platform Agenda presentation', () { + final linux = File( + 'lib/src/features/schedule/presentation/schedule_agenda_view.dart', + ).readAsStringSync(); + final windows = File( + 'lib/src/ui/windows/windows_schedule_page.dart', + ).readAsStringSync(); + final android = File( + 'lib/src/android/presentation/android_schedule_screen.dart', + ).readAsStringSync(); + + expect(linux, isNot(contains('ListView.builder('))); + expect('BusyMaxGroupedList('.allMatches(linux), hasLength(1)); + expect(linux, contains('scheduleSearchResultDisplayDate(item)')); + expect(windows, isNot(contains('groupByDate'))); + expect(windows, contains('scheduleSearchResultDisplayDate(item)')); + expect(android, contains('scheduleSearchResultDisplayDate(item)')); + expect(windows, isNot(contains('class _SearchResultList'))); + expect(android, isNot(contains('class _SearchResultList'))); + }); + test('sidebar does not render redundant provider group titles', () { final sidebar = File( 'lib/src/features/schedule/presentation/schedule_sidebar.dart', From 252af1188faafba465683a51e52b1171b785aed9 Mon Sep 17 00:00:00 2001 From: ChenZhenIO Date: Tue, 15 Sep 2026 18:44:50 -0700 Subject: [PATCH 13/25] Refactor Schedule agenda view and search result comparison logic --- .../presentation/schedule_agenda_view.dart | 14 +++- .../schedule_search_result_text.dart | 1 - .../schedule_search_result_text_test.dart | 23 ++++++ .../presentation/schedule_views_test.dart | 73 +++++++++++++++++++ 4 files changed, 106 insertions(+), 5 deletions(-) diff --git a/lib/src/features/schedule/presentation/schedule_agenda_view.dart b/lib/src/features/schedule/presentation/schedule_agenda_view.dart index 32bb2c76..b4179f70 100644 --- a/lib/src/features/schedule/presentation/schedule_agenda_view.dart +++ b/lib/src/features/schedule/presentation/schedule_agenda_view.dart @@ -187,7 +187,6 @@ class _ScheduleAgendaViewState extends State { List<_AgendaSection> _searchSections(BuildContext context) { final orderedItems = List.of(widget.items) ..sort(compareScheduleSearchResultPresentation); - final hierarchy = _AgendaHierarchyIndex(orderedItems); final datedGroups = >{}; final undated = []; for (final item in orderedItems) { @@ -200,17 +199,24 @@ class _ScheduleAgendaViewState extends State { .add(item); } } - final emittedTasks = {}; return [ for (final entry in datedGroups.entries) _AgendaSection( title: _dayLabel(context, entry.key), - children: _entriesFor(entry.value, hierarchy, emittedTasks), + children: _entriesFor( + entry.value, + _AgendaHierarchyIndex(entry.value), + {}, + ), ), if (undated.isNotEmpty) _AgendaSection( title: context.l10n.noDate, - children: _entriesFor(undated, hierarchy, emittedTasks), + children: _entriesFor( + undated, + _AgendaHierarchyIndex(undated), + {}, + ), ), ]; } diff --git a/lib/src/features/schedule/presentation/schedule_search_result_text.dart b/lib/src/features/schedule/presentation/schedule_search_result_text.dart index 98bbc685..16f863a3 100644 --- a/lib/src/features/schedule/presentation/schedule_search_result_text.dart +++ b/lib/src/features/schedule/presentation/schedule_search_result_text.dart @@ -26,7 +26,6 @@ int compareScheduleSearchResultPresentation( firstDate, ).compareTo(_searchResultDay(secondDate)); if (dayComparison != 0) return dayComparison; - if (first.allDay != second.allDay) return first.allDay ? -1 : 1; final timeComparison = _searchResultDate( firstDate, ).compareTo(_searchResultDate(secondDate)); diff --git a/test/features/schedule/presentation/schedule_search_result_text_test.dart b/test/features/schedule/presentation/schedule_search_result_text_test.dart index d7f1ab9d..957dfa04 100644 --- a/test/features/schedule/presentation/schedule_search_result_text_test.dart +++ b/test/features/schedule/presentation/schedule_search_result_text_test.dart @@ -151,4 +151,27 @@ void main() { 'undated', ]); }); + + test('search Agenda orders a timed DAV due by its actual time', () { + final values = [ + task( + due: DateTime(2026, 6, 12, 15, 45), + start: DateTime(2026, 6, 10), + allDay: true, + provider: BusyProvider.nextcloud, + ), + CalendarScheduleItem( + id: 'morning-event', + accountId: 'account', + sourceId: 'calendar', + provider: BusyProvider.google, + providerCalendarId: 'calendar', + title: 'Morning event', + allDay: false, + start: DateTime(2026, 6, 12, 9), + ), + ]..sort(compareScheduleSearchResultPresentation); + + expect(values.map((item) => item.id), ['morning-event', 'task']); + }); } diff --git a/test/features/schedule/presentation/schedule_views_test.dart b/test/features/schedule/presentation/schedule_views_test.dart index e27e30ee..16b5e96f 100644 --- a/test/features/schedule/presentation/schedule_views_test.dart +++ b/test/features/schedule/presentation/schedule_views_test.dart @@ -2981,6 +2981,79 @@ void main() { ); }); + testWidgets('search Agenda keeps cross-date children in their own section', ( + tester, + ) async { + final parentDay = DateTime(2040, 6, 12); + final childDay = DateTime(2040, 6, 20); + final criteria = ScheduleSearchCriteria( + referenceDate: parentDay, + firstWeekday: DateTime.monday, + sourceIds: const {}, + taskListKeys: { + const ScheduleTaskListKey(accountId: 'google:g', taskListId: 'tasks'), + }, + ); + final parent = TaskScheduleItem( + id: 'parent', + accountId: 'google:g', + provider: BusyProvider.google, + sourceId: 'tasks', + title: 'June parent', + completed: false, + allDay: true, + start: parentDay, + due: parentDay, + hasSubtasks: true, + sourceName: 'Projects', + ); + final child = TaskScheduleItem( + id: 'child', + accountId: 'google:g', + provider: BusyProvider.google, + sourceId: 'tasks', + title: 'Later child', + completed: false, + allDay: true, + start: childDay, + due: childDay, + parentId: 'parent', + parentTitle: 'June parent', + hierarchyDepth: 1, + sourceName: 'Projects', + ); + + await tester.pumpWidget( + localizedTestApp( + child: Scaffold( + body: ScheduleAgendaView( + range: ScheduleRange.day(parentDay), + items: [child, parent], + searchCriteria: criteria, + onItemSelected: (_, _, [_]) {}, + onTaskCompletionChanged: (_, _) {}, + ), + ), + ), + ); + + final parentGroup = tester.widget( + find.ancestor( + of: find.text('June parent'), + matching: find.byType(BusyMaxGroupedList), + ), + ); + final childGroup = tester.widget( + find.ancestor( + of: find.text('Later child'), + matching: find.byType(BusyMaxGroupedList), + ), + ); + expect(parentGroup.title, DateFormat.yMMMMEEEEd('en').format(parentDay)); + expect(childGroup.title, DateFormat.yMMMMEEEEd('en').format(childDay)); + expect(find.text('Parent: June parent'), findsOneWidget); + }); + testWidgets('agenda nests task children and Microsoft checklist steps', ( tester, ) async { From a80f6ed9de2bb47504d3b68a7625edd027ae6be4 Mon Sep 17 00:00:00 2001 From: albert Date: Fri, 18 Sep 2026 15:37:02 -0700 Subject: [PATCH 14/25] Update Flutter to 3.47.4 and Dart to 3.13.3 across toolchain, tests, and documentation. --- .github/workflows/flutter-android.yml | 2 +- .github/workflows/flutter-linux.yml | 6 +++--- .github/workflows/flutter-windows.yml | 2 +- README.md | 4 ++-- android/gradle.properties | 2 +- android/settings.gradle.kts | 2 +- docs/android_setup.md | 4 ++-- docs/android_verification.md | 4 ++++ docs/development.md | 6 +++--- docs/time_format_validation.md | 2 +- docs/windows_packaging.md | 2 +- docs/windows_release_checklist.md | 2 +- .../example/pubspec.lock | 4 ++-- .../example/pubspec.yaml | 2 +- .../busymax_android_platform/pubspec.yaml | 2 +- pubspec.lock | 2 +- test/tool/flutter_linux_workflow_test.dart | 6 +++--- test/tool/flutter_sdk_verifier_test.dart | 20 +++++++++---------- test/tool/flutter_windows_workflow_test.dart | 6 +++--- tool/android/check_prerequisites.sh | 8 ++++---- tool/windows/check_prerequisites.ps1 | 12 +++++------ 21 files changed, 52 insertions(+), 48 deletions(-) diff --git a/.github/workflows/flutter-android.yml b/.github/workflows/flutter-android.yml index a5a1b97a..0c083a3e 100644 --- a/.github/workflows/flutter-android.yml +++ b/.github/workflows/flutter-android.yml @@ -29,7 +29,7 @@ jobs: uses: subosito/flutter-action@v2 with: channel: stable - flutter-version: '3.47.2' + flutter-version: '3.47.4' cache: true - name: Install Android API and Build Tools 37 diff --git a/.github/workflows/flutter-linux.yml b/.github/workflows/flutter-linux.yml index 73740ede..37bd5aff 100644 --- a/.github/workflows/flutter-linux.yml +++ b/.github/workflows/flutter-linux.yml @@ -38,7 +38,7 @@ jobs: uses: subosito/flutter-action@v2 with: channel: stable - flutter-version: '3.47.2' + flutter-version: '3.47.4' cache: true - name: Verify pinned Flutter and bundled Dart @@ -50,8 +50,8 @@ jobs: "$dart_executable" tool/verify_flutter_sdk.dart \ --flutter "$flutter_executable" \ --dart "$dart_executable" \ - --expected-flutter 3.47.2 \ - --expected-dart 3.13.2 + --expected-flutter 3.47.4 \ + --expected-dart 3.13.3 printf 'BUSYMAX_FLUTTER_EXECUTABLE=%s\n' "$flutter_executable" >> "$GITHUB_ENV" printf 'BUSYMAX_DART_EXECUTABLE=%s\n' "$dart_executable" >> "$GITHUB_ENV" diff --git a/.github/workflows/flutter-windows.yml b/.github/workflows/flutter-windows.yml index 1dbb5bc2..afeb81ec 100644 --- a/.github/workflows/flutter-windows.yml +++ b/.github/workflows/flutter-windows.yml @@ -23,7 +23,7 @@ jobs: uses: subosito/flutter-action@v2 with: channel: stable - flutter-version: '3.47.2' + flutter-version: '3.47.4' cache: true - name: Enable Windows desktop diff --git a/README.md b/README.md index 7a583b2d..a70f60e4 100644 --- a/README.md +++ b/README.md @@ -87,8 +87,8 @@ differences and limitations are summarized in the ### Android development build Android support targets API 37 with a minimum API of 24 and uses the explicit -entrypoint `lib/main_android.dart`. It requires Flutter 3.47.2, bundled Dart -3.13.2, JDK 17, Android platform 37, and Build Tools 37.x. +entrypoint `lib/main_android.dart`. It requires Flutter 3.47.4, bundled Dart +3.13.3, JDK 17, Android platform 37, and Build Tools 37.x. ```sh tool/android/check_prerequisites.sh diff --git a/android/gradle.properties b/android/gradle.properties index 5d054887..fbfdfa1b 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -1,6 +1,6 @@ org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError android.useAndroidX=true -# Flutter 3.47.2 still casts the current AGP extension to the legacy variant +# Flutter 3.47.4 still casts the current AGP extension to the legacy variant # API, so this one compatibility flag is required until the pinned Flutter # Gradle plugin migrates. Kotlin compilation itself remains AGP 9 built-in. android.newDsl=false diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts index ca378fae..1086b33e 100644 --- a/android/settings.gradle.kts +++ b/android/settings.gradle.kts @@ -20,7 +20,7 @@ pluginManagement { plugins { id("dev.flutter.flutter-plugin-loader") version "1.0.0" id("com.android.application") version "9.2.0" apply false - // Flutter 3.47.2 validates this minimum even though AGP 9 performs the + // Flutter 3.47.4 validates this minimum even though AGP 9 performs the // actual built-in Kotlin compilation. id("org.jetbrains.kotlin.android") version "2.4.0" apply false } diff --git a/docs/android_setup.md b/docs/android_setup.md index 8b8c6e3e..37b12bd1 100644 --- a/docs/android_setup.md +++ b/docs/android_setup.md @@ -2,12 +2,12 @@ BusyMax Android is a beta application with package ID `io.busystack.busymax`, version `0.2.2+2`, minimum API 24, and compile/target -API 37. The supported toolchain is Flutter 3.47.2, its bundled Dart 3.13.2, +API 37. The supported toolchain is Flutter 3.47.4, its bundled Dart 3.13.3, JDK 17, Android platform 37, and Build Tools 37.x. The resolved Android build uses Gradle 9.4.1 and Android Gradle Plugin 9.2.0. Built-in Kotlin is enabled; the Kotlin plugin declaration is 2.4.0 so Flutter's -toolchain validation sees the supported language level. Flutter 3.47.2's own +toolchain validation sees the supported language level. Flutter 3.47.4's own Gradle integration still casts the Android application extension to the legacy type, so this pinned project retains `android.newDsl=false`. Enabling the new DSL was exercised and reproducibly fails in Flutter's Gradle plugin with an diff --git a/docs/android_verification.md b/docs/android_verification.md index 83c8f6e3..19edfb4c 100644 --- a/docs/android_verification.md +++ b/docs/android_verification.md @@ -7,6 +7,10 @@ specific external prerequisite was unavailable, and `NOT RUN` means an appropriate runner or environment was not available. A skipped live-provider test is never counted as a pass. +This is a dated verification snapshot. Its recorded Flutter 3.47.2 values are +historical evidence, not the current repository requirement; see +[Development](development.md) for the active toolchain pin. + ## Build identity and environment | Item | Recorded value | diff --git a/docs/development.md b/docs/development.md index f1fae93e..a6aae1cf 100644 --- a/docs/development.md +++ b/docs/development.md @@ -5,14 +5,14 @@ and Windows. Release packaging has separate procedures linked below. ## Shared setup -BusyMax development and CI use Flutter **3.47.2** and the Dart **3.13.2** SDK +BusyMax development and CI use Flutter **3.47.4** and the Dart **3.13.3** SDK bundled with that Flutter installation. Use the bundled `dart` executable; do not mix Flutter with a different global Dart SDK. The `pubspec.yaml` constraint `sdk: ^3.12.0` is the Dart language/package compatibility constraint. It is not the repository's build-toolchain version. The workflows verify the selected SDK with `tool/verify_flutter_sdk.dart`. -After installing Flutter 3.47.2, prepare the checkout in this order: +After installing Flutter 3.47.4, prepare the checkout in this order: ```bash flutter pub get --enforce-lockfile @@ -95,7 +95,7 @@ x86, ARM64, MSI, and EXE installers are outside the supported target. Install: -- Flutter 3.47.2 with Windows desktop enabled; +- Flutter 3.47.4 with Windows desktop enabled; - Visual Studio 2022 with **Desktop development with C++**, the x64 MSVC toolchain, and CMake tools; - Windows SDK `10.0.26100.0` or newer; and diff --git a/docs/time_format_validation.md b/docs/time_format_validation.md index eb886bfb..699e9869 100644 --- a/docs/time_format_validation.md +++ b/docs/time_format_validation.md @@ -1,6 +1,6 @@ # Time-format correction -Validated against this checkout with Flutter 3.47.2, Dart 3.13.2, +Validated against this checkout with Flutter 3.47.4, Dart 3.13.3, intl 0.20.2, and fluent_ui 4.16.1. The reported Linux picker, parser, Fluent picker, schedule boundary, and Windows tray-cache defects were present. diff --git a/docs/windows_packaging.md b/docs/windows_packaging.md index 8b6fc460..351ccea7 100644 --- a/docs/windows_packaging.md +++ b/docs/windows_packaging.md @@ -51,7 +51,7 @@ Use a 64-bit Windows 11 PowerShell prompt: -ConfigPath config\windows_store.local.json ``` -The script verifies Flutter 3.47.2 and bundled Dart 3.13.2, Visual Studio x64 +The script verifies Flutter 3.47.4 and bundled Dart 3.13.3, Visual Studio x64 C++ tools, and Windows SDK 10.0.26100.0 or newer. It runs source generation, formatting, analysis, Dart/Flutter and PowerShell tests, platform-boundary and native tests, builds `lib/main_windows.dart`, stages runtime files, validates diff --git a/docs/windows_release_checklist.md b/docs/windows_release_checklist.md index 2a88a8bf..7498fa1f 100644 --- a/docs/windows_release_checklist.md +++ b/docs/windows_release_checklist.md @@ -10,7 +10,7 @@ person/date responsible for each result. ## Automated gates -- [ ] A clean checkout uses Flutter 3.47.2 and its bundled Dart 3.13.2. +- [ ] A clean checkout uses Flutter 3.47.4 and its bundled Dart 3.13.3. - [ ] Dependency resolution, localization generation, and Drift generation follow [Development](development.md) and leave committed outputs clean. - [ ] The non-writing formatting check, analyzer, normal tests, and diff --git a/packages/busymax_android_platform/example/pubspec.lock b/packages/busymax_android_platform/example/pubspec.lock index 5f25feda..691bbe72 100644 --- a/packages/busymax_android_platform/example/pubspec.lock +++ b/packages/busymax_android_platform/example/pubspec.lock @@ -287,5 +287,5 @@ packages: source: hosted version: "3.2.0" sdks: - dart: ">=3.13.2 <4.0.0" - flutter: ">=3.18.0-18.0.pre.54" + dart: ">=3.13.3 <4.0.0" + flutter: ">=3.47.4" diff --git a/packages/busymax_android_platform/example/pubspec.yaml b/packages/busymax_android_platform/example/pubspec.yaml index 8f90e2a6..4a9f6b74 100644 --- a/packages/busymax_android_platform/example/pubspec.yaml +++ b/packages/busymax_android_platform/example/pubspec.yaml @@ -5,7 +5,7 @@ description: "Demonstrates how to use the busymax_android_platform plugin." publish_to: 'none' # Remove this line if you wish to publish to pub.dev environment: - sdk: ^3.13.2 + sdk: ^3.13.3 # Dependencies specify other packages that your package needs in order to work. # To automatically upgrade your package dependencies to the latest versions diff --git a/packages/busymax_android_platform/pubspec.yaml b/packages/busymax_android_platform/pubspec.yaml index 065065fa..386349d6 100644 --- a/packages/busymax_android_platform/pubspec.yaml +++ b/packages/busymax_android_platform/pubspec.yaml @@ -5,7 +5,7 @@ repository: https://github.com/BusyStack/BusyMax environment: sdk: ^3.12.0 - flutter: '>=3.47.2' + flutter: '>=3.47.4' dependencies: flutter: diff --git a/pubspec.lock b/pubspec.lock index 203e712c..aee5ac62 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1477,4 +1477,4 @@ packages: version: "0.1.3" sdks: dart: ">=3.12.0 <4.0.0" - flutter: ">=3.47.2" + flutter: ">=3.47.4" diff --git a/test/tool/flutter_linux_workflow_test.dart b/test/tool/flutter_linux_workflow_test.dart index c97258f0..553d3667 100644 --- a/test/tool/flutter_linux_workflow_test.dart +++ b/test/tool/flutter_linux_workflow_test.dart @@ -9,7 +9,7 @@ void main() { ).readAsStringSync(); expect(workflow, contains('runs-on: ubuntu-24.04')); - expect(workflow, contains("flutter-version: '3.47.2'")); + expect(workflow, contains("flutter-version: '3.47.4'")); expect(workflow, contains('Verify pinned Flutter and bundled Dart')); expect(workflow, contains(r'command -v flutter')); expect( @@ -17,8 +17,8 @@ void main() { contains(r'dart_executable="$flutter_bin/cache/dart-sdk/bin/dart"'), ); expect(workflow, contains('tool/verify_flutter_sdk.dart')); - expect(workflow, contains('--expected-flutter 3.47.2')); - expect(workflow, contains('--expected-dart 3.13.2')); + expect(workflow, contains('--expected-flutter 3.47.4')); + expect(workflow, contains('--expected-dart 3.13.3')); expect(workflow, isNot(contains(r'command -v dart'))); expect( workflow, diff --git a/test/tool/flutter_sdk_verifier_test.dart b/test/tool/flutter_sdk_verifier_test.dart index 61628a57..0a00b11d 100644 --- a/test/tool/flutter_sdk_verifier_test.dart +++ b/test/tool/flutter_sdk_verifier_test.dart @@ -13,8 +13,8 @@ void main() { expect(result.exitCode, 0, reason: result.stderr.toString()); final report = jsonDecode(result.stdout.toString()); expect(report, isA>()); - expect(report['flutterVersion'], '3.47.2'); - expect(report['dartVersion'], '3.13.2'); + expect(report['flutterVersion'], '3.47.4'); + expect(report['dartVersion'], '3.13.3'); expect( await FileSystemEntity.identical( report['dartExecutable']! as String, @@ -48,7 +48,7 @@ void main() { expect(result.exitCode, 0, reason: result.stderr.toString()); final report = jsonDecode(result.stdout.toString()) as Map; - expect(report['dartVersion'], '3.13.2'); + expect(report['dartVersion'], '3.13.3'); final verifiedDart = report['dartExecutable']! as String; for (final arguments in [ @@ -83,7 +83,7 @@ void main() { final siblingDart = File( '${fixture.root.path}/bin/${_commandName('dart')}', ); - await _writeExecutable(siblingDart, dartVersion: '3.13.2'); + await _writeExecutable(siblingDart, dartVersion: '3.13.3'); final result = await fixture.verify(dart: siblingDart); @@ -112,8 +112,8 @@ final class _ToolchainFixture { final bundledDart = File( '${root.path}/bin/cache/dart-sdk/bin/${_commandName('dart')}', ); - await _writeExecutable(flutter, flutterVersion: '3.47.2'); - await _writeExecutable(bundledDart, dartVersion: '3.13.2'); + await _writeExecutable(flutter, flutterVersion: '3.47.4'); + await _writeExecutable(bundledDart, dartVersion: '3.13.3'); return _ToolchainFixture( root: root, flutter: flutter, @@ -133,9 +133,9 @@ final class _ToolchainFixture { '--dart', (dart ?? bundledDart).path, '--expected-flutter', - '3.47.2', + '3.47.4', '--expected-dart', - '3.13.2', + '3.13.3', ], environment: environment, runInShell: Platform.isWindows, @@ -172,7 +172,7 @@ Future _writeExecutable( if (Platform.isWindows) { final output = flutterVersion != null ? '{"frameworkVersion":"$flutterVersion",' - '"dartSdkVersion":"3.13.2"}' + '"dartSdkVersion":"3.13.3"}' : 'Dart SDK version: $dartVersion (stable) on "windows_x64"'; await file.writeAsString( '@echo off\r\n' @@ -184,7 +184,7 @@ Future _writeExecutable( } final output = flutterVersion != null ? '{"frameworkVersion":"$flutterVersion",' - '"dartSdkVersion":"3.13.2"}' + '"dartSdkVersion":"3.13.3"}' : 'Dart SDK version: $dartVersion (stable) on "linux_x64"'; await file.writeAsString( '#!/bin/sh\n' diff --git a/test/tool/flutter_windows_workflow_test.dart b/test/tool/flutter_windows_workflow_test.dart index d586b330..cc85b646 100644 --- a/test/tool/flutter_windows_workflow_test.dart +++ b/test/tool/flutter_windows_workflow_test.dart @@ -37,7 +37,7 @@ void main() { 'tool/windows/check_prerequisites.ps1', ).readAsStringSync(); - expect(workflow, contains("flutter-version: '3.47.2'")); + expect(workflow, contains("flutter-version: '3.47.4'")); for (final name in [ 'Verify x64 Windows build host', @@ -120,8 +120,8 @@ void main() { expect(prerequisiteScript, contains('-All')); expect(prerequisiteScript, contains("'cache\\dart-sdk\\bin\\dart.exe'")); expect(prerequisiteScript, contains('verify_flutter_sdk.dart')); - expect(prerequisiteScript, contains("flutterVersion -ne '3.47.2'")); - expect(prerequisiteScript, contains("dartVersion -ne '3.13.2'")); + expect(prerequisiteScript, contains("flutterVersion -ne '3.47.4'")); + expect(prerequisiteScript, contains("dartVersion -ne '3.13.3'")); expect(prerequisiteScript, isNot(contains('Get-Command dart'))); expect(prerequisiteScript, isNot(contains(r'$flutterBin -ne $dartBin'))); }); diff --git a/tool/android/check_prerequisites.sh b/tool/android/check_prerequisites.sh index d6996000..d9a17b24 100755 --- a/tool/android/check_prerequisites.sh +++ b/tool/android/check_prerequisites.sh @@ -14,7 +14,7 @@ done flutter_bin="${BUSYMAX_FLUTTER_EXECUTABLE:-$(command -v flutter || true)}" if [[ -z "$flutter_bin" || ! -x "$flutter_bin" ]]; then - echo 'Flutter is missing. Install Flutter 3.47.2 and put it on PATH.' >&2 + echo 'Flutter is missing. Install Flutter 3.47.4 and put it on PATH.' >&2 exit 1 fi flutter_bin="$(readlink -f "$flutter_bin")" @@ -26,8 +26,8 @@ fi "$dart_bin" "$repo_root/tool/verify_flutter_sdk.dart" \ --flutter "$flutter_bin" \ --dart "$dart_bin" \ - --expected-flutter 3.47.2 \ - --expected-dart 3.13.2 + --expected-flutter 3.47.4 \ + --expected-dart 3.13.3 java_version="$(java -version 2>&1 | sed -n '1s/.*version "\([^"]*\)".*/\1/p')" if [[ "$java_version" != 17.* ]]; then @@ -81,6 +81,6 @@ if $require_signing; then done fi -echo 'PASSED: Flutter 3.47.2, bundled Dart 3.13.2, JDK 17, Android API 37, and Build Tools 37.x are available.' +echo 'PASSED: Flutter 3.47.4, bundled Dart 3.13.3, JDK 17, Android API 37, and Build Tools 37.x are available.' echo "Flutter: $flutter_bin" echo "Android SDK: $sdk_root" diff --git a/tool/windows/check_prerequisites.ps1 b/tool/windows/check_prerequisites.ps1 index 4557ed8d..14dcce9e 100644 --- a/tool/windows/check_prerequisites.ps1 +++ b/tool/windows/check_prerequisites.ps1 @@ -41,19 +41,19 @@ $toolchainVerifier = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot ` $toolchainJson = & $dartExecutable $toolchainVerifier ` --flutter $flutterExecutable ` --dart $dartExecutable ` - --expected-flutter 3.47.2 ` - --expected-dart 3.13.2 + --expected-flutter 3.47.4 ` + --expected-dart 3.13.3 if ($LASTEXITCODE -ne 0) { throw 'Flutter SDK verification failed.' } $toolchain = $toolchainJson | ConvertFrom-Json $flutterVersion = [string]$toolchain.flutterVersion $dartVersion = [string]$toolchain.dartVersion -if ($flutterVersion -ne '3.47.2') { - throw "BusyMax requires Flutter 3.47.2; found $flutterVersion." +if ($flutterVersion -ne '3.47.4') { + throw "BusyMax requires Flutter 3.47.4; found $flutterVersion." } -if ($dartVersion -ne '3.13.2') { - throw "Flutter 3.47.2 must provide Dart 3.13.2; found $dartVersion." +if ($dartVersion -ne '3.13.3') { + throw "Flutter 3.47.4 must provide Dart 3.13.3; found $dartVersion." } $vswhere = Join-Path ${env:ProgramFiles(x86)} 'Microsoft Visual Studio\Installer\vswhere.exe' if (-not (Test-Path -LiteralPath $vswhere)) { From 8a73d1b54002b1e62b68be7f543544d234225ec9 Mon Sep 17 00:00:00 2001 From: albert Date: Fri, 18 Sep 2026 16:54:57 -0700 Subject: [PATCH 15/25] =?UTF-8?q?Implemented=20the=20=E2=80=9CFirst=20day?= =?UTF-8?q?=20of=20the=20week=E2=80=9D=20preference=20across=20Linux,=20Wi?= =?UTF-8?q?ndows,=20and=20Android?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/l10n/app_ar.arb | 3 + lib/l10n/app_de.arb | 3 + lib/l10n/app_en.arb | 4 + lib/l10n/app_es.arb | 3 + lib/l10n/app_et.arb | 3 + lib/l10n/app_fa.arb | 3 + lib/l10n/app_fi.arb | 3 + lib/l10n/app_fr.arb | 3 + lib/l10n/app_hi.arb | 3 + lib/l10n/app_it.arb | 3 + lib/l10n/app_ja.arb | 3 + lib/l10n/app_ko.arb | 3 + lib/l10n/app_pl.arb | 3 + lib/l10n/app_pt_PT.arb | 3 + lib/l10n/app_ru.arb | 3 + lib/l10n/app_vi.arb | 3 + lib/l10n/app_zh.arb | 3 + lib/l10n/app_zh_Hans.arb | 3 + lib/l10n/app_zh_Hant.arb | 3 + lib/l10n/generated/app_localizations.dart | 18 + lib/l10n/generated/app_localizations_ar.dart | 11 + lib/l10n/generated/app_localizations_de.dart | 11 + lib/l10n/generated/app_localizations_en.dart | 11 + lib/l10n/generated/app_localizations_es.dart | 11 + lib/l10n/generated/app_localizations_et.dart | 11 + lib/l10n/generated/app_localizations_fa.dart | 11 + lib/l10n/generated/app_localizations_fi.dart | 11 + lib/l10n/generated/app_localizations_fr.dart | 11 + lib/l10n/generated/app_localizations_hi.dart | 11 + lib/l10n/generated/app_localizations_it.dart | 11 + lib/l10n/generated/app_localizations_ja.dart | 11 + lib/l10n/generated/app_localizations_ko.dart | 11 + lib/l10n/generated/app_localizations_pl.dart | 11 + lib/l10n/generated/app_localizations_pt.dart | 22 + lib/l10n/generated/app_localizations_ru.dart | 11 + lib/l10n/generated/app_localizations_vi.dart | 11 + lib/l10n/generated/app_localizations_zh.dart | 33 ++ lib/src/android/android_app.dart | 41 +- .../presentation/android_date_picker.dart | 422 ++++++++++++++++++ .../presentation/android_schedule_screen.dart | 22 +- .../android_schedule_search_filters.dart | 3 +- .../presentation/android_settings_screen.dart | 34 ++ .../presentation/android_tasks_screen.dart | 13 +- lib/src/app/app_settings.dart | 18 + lib/src/app/busymax_app.dart | 32 +- lib/src/app/windows/windows_busymax_app.dart | 29 +- .../presentation/recurrence_editor.dart | 1 - .../presentation/schedule_search_filters.dart | 1 - .../presentation/schedule_workspace.dart | 17 +- .../presentation/settings_screen.dart | 10 + .../desktop_date_time_fields.dart | 84 +--- .../presentation/ical_task_fields_editor.dart | 2 - .../presentation/task_details_editor.dart | 3 - lib/src/l10n/first_day_of_week.dart | 95 ++++ lib/src/l10n/week_preferences_scope.dart | 137 ++++++ .../android/android_first_weekday_source.dart | 19 + .../platform/linux_first_weekday_source.dart | 28 ++ .../windows/windows_first_weekday_source.dart | 44 ++ lib/src/schedule/schedule_range.dart | 10 +- .../schedule/schedule_search_criteria.dart | 7 +- lib/src/ui/windows/windows_schedule_page.dart | 52 ++- .../windows/windows_schedule_source_pane.dart | 9 +- lib/src/ui/windows/windows_settings_page.dart | 23 + linux/runner/my_application.cc | 108 ++--- .../android/build.gradle.kts | 1 + .../BusymaxAndroidPlatformPlugin.kt | 16 + .../BusymaxAndroidPlatformPluginTest.kt | 19 + .../lib/busymax_android_platform.dart | 4 + test/app/app_settings_controller_test.dart | 27 ++ test/app/app_settings_test.dart | 20 + test/app/first_day_of_week_test.dart | 177 ++++++++ test/app/native_ui_audit_test.dart | 4 +- .../remembered_coordinate_details_test.dart | 9 +- .../schedule_search_filters_test.dart | 44 +- .../presentation/schedule_toolbar_test.dart | 50 ++- .../presentation/schedule_views_test.dart | 84 +++- test/features/schedule/schedule_dst_test.dart | 26 +- .../schedule/schedule_search_test.dart | 26 ++ .../presentation/settings_screen_test.dart | 32 ++ .../desktop_date_time_fields_test.dart | 4 +- .../presentation/task_details_pane_test.dart | 2 - .../android/android_adaptive_app_test.dart | 45 +- .../android/android_date_picker_test.dart | 109 +++++ .../android_settings_persistence_test.dart | 11 + .../windows_clock_preference_test.dart | 39 ++ .../windows_schedule_source_pane_test.dart | 2 + .../windows_settings_startup_test.dart | 20 + .../windows_calendar_overview_test.dart | 7 +- .../windows_schedule_planner_test.dart | 6 +- windows/runner/flutter_window.cpp | 35 ++ windows/runner/flutter_window.h | 5 + windows/runner/tests/single_instance_test.cpp | 31 ++ windows/runner/weekday_preference.h | 21 + 93 files changed, 2169 insertions(+), 267 deletions(-) create mode 100644 lib/src/android/presentation/android_date_picker.dart create mode 100644 lib/src/l10n/first_day_of_week.dart create mode 100644 lib/src/l10n/week_preferences_scope.dart create mode 100644 lib/src/platform/android/android_first_weekday_source.dart create mode 100644 lib/src/platform/linux_first_weekday_source.dart create mode 100644 lib/src/platform/windows/windows_first_weekday_source.dart create mode 100644 test/app/first_day_of_week_test.dart create mode 100644 test/platform/android/android_date_picker_test.dart create mode 100644 windows/runner/weekday_preference.h diff --git a/lib/l10n/app_ar.arb b/lib/l10n/app_ar.arb index 55f0355d..7bba1cef 100644 --- a/lib/l10n/app_ar.arb +++ b/lib/l10n/app_ar.arb @@ -1,6 +1,9 @@ { "launchAtLoginManagedExternally": "تم تشغيل BusyMax من إعدادات بدء التشغيل في سطح المكتب. أزل إدخاله هناك، ثم أنهِ BusyMax وافتحه مجددًا لاستخدام هذا المفتاح.", "timeFormat": "تنسيق الوقت", + "firstDayOfWeek": "أول أيام الأسبوع", + "systemDefault": "الإعداد الافتراضي للنظام", + "systemDefaultResolved": "الإعداد الافتراضي للنظام (⁨{weekday}⁩)", "timeFormatTwelveHour": "12 ساعة", "timeFormatTwentyFourHour": "24 ساعة", "timeEndOfDay": "نهاية اليوم", diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 745fbf25..f50e4bd9 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -1,6 +1,9 @@ { "launchAtLoginManagedExternally": "BusyMax wurde durch die Autostart-Einstellungen Ihres Desktops gestartet. Entfernen Sie dort den Eintrag, beenden Sie BusyMax und öffnen Sie es erneut, um diesen Schalter zu verwenden.", "timeFormat": "Zeitformat", + "firstDayOfWeek": "Erster Wochentag", + "systemDefault": "Systemstandard", + "systemDefaultResolved": "Systemstandard ({weekday})", "timeFormatTwelveHour": "12 Stunden", "timeFormatTwentyFourHour": "24 Stunden", "timeEndOfDay": "Tagesende", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index db18c680..a0b53c87 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1,6 +1,10 @@ { "launchAtLoginManagedExternally": "BusyMax was launched by your desktop's startup settings. Remove its entry there, then quit and reopen BusyMax to use this switch.", "timeFormat": "Time format", + "firstDayOfWeek": "First day of the week", + "systemDefault": "System default", + "systemDefaultResolved": "System default ({weekday})", + "@systemDefaultResolved": {"placeholders": {"weekday": {"type": "String"}}}, "timeFormatTwelveHour": "12-hour", "timeFormatTwentyFourHour": "24-hour", "timeEndOfDay": "end of day", diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 20cd0251..e70e9026 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -1,6 +1,9 @@ { "launchAtLoginManagedExternally": "BusyMax se inició desde los ajustes de inicio del escritorio. Elimina su entrada allí y luego sal de BusyMax y vuelve a abrirlo para usar este interruptor.", "timeFormat": "Formato de hora", + "firstDayOfWeek": "Primer día de la semana", + "systemDefault": "Predeterminado del sistema", + "systemDefaultResolved": "Predeterminado del sistema ({weekday})", "timeFormatTwelveHour": "12 horas", "timeFormatTwentyFourHour": "24 horas", "timeEndOfDay": "fin del día", diff --git a/lib/l10n/app_et.arb b/lib/l10n/app_et.arb index 65daff73..c454497b 100644 --- a/lib/l10n/app_et.arb +++ b/lib/l10n/app_et.arb @@ -1,6 +1,9 @@ { "launchAtLoginManagedExternally": "BusyMax käivitati töölaua automaatkäivituse seadete kaudu. Eemalda sealt selle kirje, seejärel sulge BusyMax täielikult ja ava see lüliti kasutamiseks uuesti.", "timeFormat": "Kellaajavorming", + "firstDayOfWeek": "Nädala esimene päev", + "systemDefault": "Süsteemi vaikeväärtus", + "systemDefaultResolved": "Süsteemi vaikeväärtus ({weekday})", "timeFormatTwelveHour": "12-tunnine", "timeFormatTwentyFourHour": "24-tunnine", "timeEndOfDay": "päeva lõpp", diff --git a/lib/l10n/app_fa.arb b/lib/l10n/app_fa.arb index dbb7c1e2..2d2b1e71 100644 --- a/lib/l10n/app_fa.arb +++ b/lib/l10n/app_fa.arb @@ -1,6 +1,9 @@ { "launchAtLoginManagedExternally": "BusyMax از تنظیمات راه‌اندازی خودکار میزکار شما اجرا شده است. ورودی آن را در آنجا حذف کنید، سپس از BusyMax خارج شوید و دوباره آن را باز کنید تا بتوانید از این کلید استفاده کنید.", "timeFormat": "قالب زمان", + "firstDayOfWeek": "روز اول هفته", + "systemDefault": "پیش‌فرض سیستم", + "systemDefaultResolved": "پیش‌فرض سیستم (⁨{weekday}⁩)", "timeFormatTwelveHour": "۱۲ ساعته", "timeFormatTwentyFourHour": "۲۴ ساعته", "timeEndOfDay": "پایان روز", diff --git a/lib/l10n/app_fi.arb b/lib/l10n/app_fi.arb index cf7422fd..ce86ccdd 100644 --- a/lib/l10n/app_fi.arb +++ b/lib/l10n/app_fi.arb @@ -1,6 +1,9 @@ { "launchAtLoginManagedExternally": "BusyMax käynnistettiin työpöydän automaattisen käynnistyksen asetuksista. Poista sen merkintä sieltä, lopeta BusyMax ja avaa se uudelleen, jotta voit käyttää tätä kytkintä.", "timeFormat": "Aikamuoto", + "firstDayOfWeek": "Viikon ensimmäinen päivä", + "systemDefault": "Järjestelmän oletus", + "systemDefaultResolved": "Järjestelmän oletus ({weekday})", "timeFormatTwelveHour": "12 tuntia", "timeFormatTwentyFourHour": "24 tuntia", "timeEndOfDay": "vuorokauden loppu", diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 404d69a1..ba1f2146 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -1,6 +1,9 @@ { "launchAtLoginManagedExternally": "BusyMax a été lancé par les paramètres de démarrage de votre bureau. Supprimez son entrée à cet endroit, puis quittez et rouvrez BusyMax pour utiliser cet interrupteur.", "timeFormat": "Format de l’heure", + "firstDayOfWeek": "Premier jour de la semaine", + "systemDefault": "Valeur par défaut du système", + "systemDefaultResolved": "Valeur par défaut du système ({weekday})", "timeFormatTwelveHour": "12 heures", "timeFormatTwentyFourHour": "24 heures", "timeEndOfDay": "fin de journée", diff --git a/lib/l10n/app_hi.arb b/lib/l10n/app_hi.arb index 38d0a80f..a6ae8cd3 100644 --- a/lib/l10n/app_hi.arb +++ b/lib/l10n/app_hi.arb @@ -1,6 +1,9 @@ { "launchAtLoginManagedExternally": "BusyMax आपके डेस्कटॉप की स्टार्टअप सेटिंग से शुरू हुआ था। वहाँ से इसकी प्रविष्टि हटाएँ, फिर इस स्विच का उपयोग करने के लिए BusyMax से बाहर निकलें और इसे दोबारा खोलें।", "timeFormat": "समय प्रारूप", + "firstDayOfWeek": "सप्ताह का पहला दिन", + "systemDefault": "सिस्टम डिफ़ॉल्ट", + "systemDefaultResolved": "सिस्टम डिफ़ॉल्ट ({weekday})", "timeFormatTwelveHour": "12 घंटे", "timeFormatTwentyFourHour": "24 घंटे", "timeEndOfDay": "दिन का अंत", diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb index 458802e4..2c9b75cb 100644 --- a/lib/l10n/app_it.arb +++ b/lib/l10n/app_it.arb @@ -1,6 +1,9 @@ { "launchAtLoginManagedExternally": "BusyMax è stato avviato dalle impostazioni di avvio del desktop. Rimuovi la sua voce da lì, poi esci da BusyMax e riaprilo per usare questo interruttore.", "timeFormat": "Formato ora", + "firstDayOfWeek": "Primo giorno della settimana", + "systemDefault": "Predefinito di sistema", + "systemDefaultResolved": "Predefinito di sistema ({weekday})", "timeFormatTwelveHour": "12 ore", "timeFormatTwentyFourHour": "24 ore", "timeEndOfDay": "fine giornata", diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index 618f3cd4..2cdeaca7 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -1,6 +1,9 @@ { "launchAtLoginManagedExternally": "BusyMax はデスクトップの自動起動設定によって起動されました。このスイッチを使うには、そこで BusyMax の項目を削除してから、BusyMax を終了して開き直してください。", "timeFormat": "時刻の表示形式", + "firstDayOfWeek": "週の最初の曜日", + "systemDefault": "システムの既定値", + "systemDefaultResolved": "システムの既定値({weekday})", "timeFormatTwelveHour": "12時間表示", "timeFormatTwentyFourHour": "24時間表示", "timeEndOfDay": "一日の終わり", diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index c3b9bd98..f25807ee 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -1,6 +1,9 @@ { "launchAtLoginManagedExternally": "데스크톱의 시작 프로그램 설정에 의해 BusyMax가 실행되었습니다. 이 스위치를 사용하려면 해당 설정에서 BusyMax 항목을 제거한 다음 BusyMax를 완전히 종료하고 다시 여세요.", "timeFormat": "시간 형식", + "firstDayOfWeek": "한 주의 시작 요일", + "systemDefault": "시스템 기본값", + "systemDefaultResolved": "시스템 기본값({weekday})", "timeFormatTwelveHour": "12시간", "timeFormatTwentyFourHour": "24시간", "timeEndOfDay": "하루의 끝", diff --git a/lib/l10n/app_pl.arb b/lib/l10n/app_pl.arb index dd167dcf..edc8996f 100644 --- a/lib/l10n/app_pl.arb +++ b/lib/l10n/app_pl.arb @@ -1,6 +1,9 @@ { "launchAtLoginManagedExternally": "BusyMax został uruchomiony przez ustawienia autostartu pulpitu. Usuń stamtąd jego wpis, a następnie zakończ i ponownie uruchom BusyMax, aby użyć tego przełącznika.", "timeFormat": "Format czasu", + "firstDayOfWeek": "Pierwszy dzień tygodnia", + "systemDefault": "Domyślne ustawienie systemu", + "systemDefaultResolved": "Domyślne ustawienie systemu ({weekday})", "timeFormatTwelveHour": "12-godzinny", "timeFormatTwentyFourHour": "24-godzinny", "timeEndOfDay": "koniec dnia", diff --git a/lib/l10n/app_pt_PT.arb b/lib/l10n/app_pt_PT.arb index e7fbece0..ad3b62b7 100644 --- a/lib/l10n/app_pt_PT.arb +++ b/lib/l10n/app_pt_PT.arb @@ -1,6 +1,9 @@ { "launchAtLoginManagedExternally": "O BusyMax foi iniciado pelas definições de arranque do ambiente de trabalho. Remova a respetiva entrada, depois saia do BusyMax e volte a abri-lo para utilizar este interruptor.", "timeFormat": "Formato da hora", + "firstDayOfWeek": "Primeiro dia da semana", + "systemDefault": "Predefinição do sistema", + "systemDefaultResolved": "Predefinição do sistema ({weekday})", "timeFormatTwelveHour": "12 horas", "timeFormatTwentyFourHour": "24 horas", "timeEndOfDay": "fim do dia", diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 4fe8a359..b4f4cfa5 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -1,6 +1,9 @@ { "launchAtLoginManagedExternally": "BusyMax запущен через настройки автозапуска рабочего стола. Удалите его запись там, затем полностью завершите и снова откройте BusyMax, чтобы использовать этот переключатель.", "timeFormat": "Формат времени", + "firstDayOfWeek": "Первый день недели", + "systemDefault": "Системное значение", + "systemDefaultResolved": "Системное значение ({weekday})", "timeFormatTwelveHour": "12 часов", "timeFormatTwentyFourHour": "24 часа", "timeEndOfDay": "конец дня", diff --git a/lib/l10n/app_vi.arb b/lib/l10n/app_vi.arb index 29ae732f..2cba9cd8 100644 --- a/lib/l10n/app_vi.arb +++ b/lib/l10n/app_vi.arb @@ -1,6 +1,9 @@ { "launchAtLoginManagedExternally": "BusyMax được mở bởi cài đặt khởi động của môi trường máy tính. Xóa mục BusyMax ở đó, rồi thoát hẳn và mở lại BusyMax để dùng công tắc này.", "timeFormat": "Định dạng giờ", + "firstDayOfWeek": "Ngày đầu tuần", + "systemDefault": "Mặc định hệ thống", + "systemDefaultResolved": "Mặc định hệ thống ({weekday})", "timeFormatTwelveHour": "12 giờ", "timeFormatTwentyFourHour": "24 giờ", "timeEndOfDay": "cuối ngày", diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index a9ecfd74..f87c01fe 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -1,6 +1,9 @@ { "launchAtLoginManagedExternally": "BusyMax 由桌面的启动设置启动。请在那里移除 BusyMax 的条目,然后完全退出并重新打开 BusyMax,以使用此开关。", "timeFormat": "时间格式", + "firstDayOfWeek": "一周的第一天", + "systemDefault": "系统默认", + "systemDefaultResolved": "系统默认({weekday})", "timeFormatTwelveHour": "12小时制", "timeFormatTwentyFourHour": "24小时制", "timeEndOfDay": "当天结束", diff --git a/lib/l10n/app_zh_Hans.arb b/lib/l10n/app_zh_Hans.arb index 8c7debf8..124fcb11 100644 --- a/lib/l10n/app_zh_Hans.arb +++ b/lib/l10n/app_zh_Hans.arb @@ -1,6 +1,9 @@ { "launchAtLoginManagedExternally": "BusyMax 由桌面的启动设置启动。请在那里移除 BusyMax 的条目,然后完全退出并重新打开 BusyMax,以使用此开关。", "timeFormat": "时间格式", + "firstDayOfWeek": "一周的第一天", + "systemDefault": "系统默认", + "systemDefaultResolved": "系统默认({weekday})", "timeFormatTwelveHour": "12小时制", "timeFormatTwentyFourHour": "24小时制", "timeEndOfDay": "当天结束", diff --git a/lib/l10n/app_zh_Hant.arb b/lib/l10n/app_zh_Hant.arb index 573a4791..d548d43a 100644 --- a/lib/l10n/app_zh_Hant.arb +++ b/lib/l10n/app_zh_Hant.arb @@ -1,6 +1,9 @@ { "launchAtLoginManagedExternally": "BusyMax 由桌面的啟動設定啟動。請在那裡移除 BusyMax 的項目,然後完全結束並重新開啟 BusyMax,以使用此開關。", "timeFormat": "時間格式", + "firstDayOfWeek": "一週的第一天", + "systemDefault": "系統預設", + "systemDefaultResolved": "系統預設({weekday})", "timeFormatTwelveHour": "12小時制", "timeFormatTwentyFourHour": "24小時制", "timeEndOfDay": "當天結束", diff --git a/lib/l10n/generated/app_localizations.dart b/lib/l10n/generated/app_localizations.dart index c404d232..971aef93 100644 --- a/lib/l10n/generated/app_localizations.dart +++ b/lib/l10n/generated/app_localizations.dart @@ -147,6 +147,24 @@ abstract class AppLocalizations { /// **'Time format'** String get timeFormat; + /// No description provided for @firstDayOfWeek. + /// + /// In en, this message translates to: + /// **'First day of the week'** + String get firstDayOfWeek; + + /// No description provided for @systemDefault. + /// + /// In en, this message translates to: + /// **'System default'** + String get systemDefault; + + /// No description provided for @systemDefaultResolved. + /// + /// In en, this message translates to: + /// **'System default ({weekday})'** + String systemDefaultResolved(String weekday); + /// No description provided for @timeFormatTwelveHour. /// /// In en, this message translates to: diff --git a/lib/l10n/generated/app_localizations_ar.dart b/lib/l10n/generated/app_localizations_ar.dart index 1f902a87..f93994a1 100644 --- a/lib/l10n/generated/app_localizations_ar.dart +++ b/lib/l10n/generated/app_localizations_ar.dart @@ -19,6 +19,17 @@ class AppLocalizationsAr extends AppLocalizations { @override String get timeFormat => 'تنسيق الوقت'; + @override + String get firstDayOfWeek => 'أول أيام الأسبوع'; + + @override + String get systemDefault => 'الإعداد الافتراضي للنظام'; + + @override + String systemDefaultResolved(String weekday) { + return 'الإعداد الافتراضي للنظام (⁨$weekday⁩)'; + } + @override String get timeFormatTwelveHour => '12 ساعة'; diff --git a/lib/l10n/generated/app_localizations_de.dart b/lib/l10n/generated/app_localizations_de.dart index 137ea4cf..f67bb9a1 100644 --- a/lib/l10n/generated/app_localizations_de.dart +++ b/lib/l10n/generated/app_localizations_de.dart @@ -19,6 +19,17 @@ class AppLocalizationsDe extends AppLocalizations { @override String get timeFormat => 'Zeitformat'; + @override + String get firstDayOfWeek => 'Erster Wochentag'; + + @override + String get systemDefault => 'Systemstandard'; + + @override + String systemDefaultResolved(String weekday) { + return 'Systemstandard ($weekday)'; + } + @override String get timeFormatTwelveHour => '12 Stunden'; diff --git a/lib/l10n/generated/app_localizations_en.dart b/lib/l10n/generated/app_localizations_en.dart index d3901c5f..7f488488 100644 --- a/lib/l10n/generated/app_localizations_en.dart +++ b/lib/l10n/generated/app_localizations_en.dart @@ -19,6 +19,17 @@ class AppLocalizationsEn extends AppLocalizations { @override String get timeFormat => 'Time format'; + @override + String get firstDayOfWeek => 'First day of the week'; + + @override + String get systemDefault => 'System default'; + + @override + String systemDefaultResolved(String weekday) { + return 'System default ($weekday)'; + } + @override String get timeFormatTwelveHour => '12-hour'; diff --git a/lib/l10n/generated/app_localizations_es.dart b/lib/l10n/generated/app_localizations_es.dart index 9044ace8..bcfc3595 100644 --- a/lib/l10n/generated/app_localizations_es.dart +++ b/lib/l10n/generated/app_localizations_es.dart @@ -19,6 +19,17 @@ class AppLocalizationsEs extends AppLocalizations { @override String get timeFormat => 'Formato de hora'; + @override + String get firstDayOfWeek => 'Primer día de la semana'; + + @override + String get systemDefault => 'Predeterminado del sistema'; + + @override + String systemDefaultResolved(String weekday) { + return 'Predeterminado del sistema ($weekday)'; + } + @override String get timeFormatTwelveHour => '12 horas'; diff --git a/lib/l10n/generated/app_localizations_et.dart b/lib/l10n/generated/app_localizations_et.dart index ed5c197a..59d2fe70 100644 --- a/lib/l10n/generated/app_localizations_et.dart +++ b/lib/l10n/generated/app_localizations_et.dart @@ -19,6 +19,17 @@ class AppLocalizationsEt extends AppLocalizations { @override String get timeFormat => 'Kellaajavorming'; + @override + String get firstDayOfWeek => 'Nädala esimene päev'; + + @override + String get systemDefault => 'Süsteemi vaikeväärtus'; + + @override + String systemDefaultResolved(String weekday) { + return 'Süsteemi vaikeväärtus ($weekday)'; + } + @override String get timeFormatTwelveHour => '12-tunnine'; diff --git a/lib/l10n/generated/app_localizations_fa.dart b/lib/l10n/generated/app_localizations_fa.dart index 91d22c82..d12a8570 100644 --- a/lib/l10n/generated/app_localizations_fa.dart +++ b/lib/l10n/generated/app_localizations_fa.dart @@ -19,6 +19,17 @@ class AppLocalizationsFa extends AppLocalizations { @override String get timeFormat => 'قالب زمان'; + @override + String get firstDayOfWeek => 'روز اول هفته'; + + @override + String get systemDefault => 'پیش‌فرض سیستم'; + + @override + String systemDefaultResolved(String weekday) { + return 'پیش‌فرض سیستم (⁨$weekday⁩)'; + } + @override String get timeFormatTwelveHour => '۱۲ ساعته'; diff --git a/lib/l10n/generated/app_localizations_fi.dart b/lib/l10n/generated/app_localizations_fi.dart index ab13931c..77de90c6 100644 --- a/lib/l10n/generated/app_localizations_fi.dart +++ b/lib/l10n/generated/app_localizations_fi.dart @@ -19,6 +19,17 @@ class AppLocalizationsFi extends AppLocalizations { @override String get timeFormat => 'Aikamuoto'; + @override + String get firstDayOfWeek => 'Viikon ensimmäinen päivä'; + + @override + String get systemDefault => 'Järjestelmän oletus'; + + @override + String systemDefaultResolved(String weekday) { + return 'Järjestelmän oletus ($weekday)'; + } + @override String get timeFormatTwelveHour => '12 tuntia'; diff --git a/lib/l10n/generated/app_localizations_fr.dart b/lib/l10n/generated/app_localizations_fr.dart index 2afc94d0..b3ea7fc3 100644 --- a/lib/l10n/generated/app_localizations_fr.dart +++ b/lib/l10n/generated/app_localizations_fr.dart @@ -19,6 +19,17 @@ class AppLocalizationsFr extends AppLocalizations { @override String get timeFormat => 'Format de l’heure'; + @override + String get firstDayOfWeek => 'Premier jour de la semaine'; + + @override + String get systemDefault => 'Valeur par défaut du système'; + + @override + String systemDefaultResolved(String weekday) { + return 'Valeur par défaut du système ($weekday)'; + } + @override String get timeFormatTwelveHour => '12 heures'; diff --git a/lib/l10n/generated/app_localizations_hi.dart b/lib/l10n/generated/app_localizations_hi.dart index 8646139e..194ad5ab 100644 --- a/lib/l10n/generated/app_localizations_hi.dart +++ b/lib/l10n/generated/app_localizations_hi.dart @@ -19,6 +19,17 @@ class AppLocalizationsHi extends AppLocalizations { @override String get timeFormat => 'समय प्रारूप'; + @override + String get firstDayOfWeek => 'सप्ताह का पहला दिन'; + + @override + String get systemDefault => 'सिस्टम डिफ़ॉल्ट'; + + @override + String systemDefaultResolved(String weekday) { + return 'सिस्टम डिफ़ॉल्ट ($weekday)'; + } + @override String get timeFormatTwelveHour => '12 घंटे'; diff --git a/lib/l10n/generated/app_localizations_it.dart b/lib/l10n/generated/app_localizations_it.dart index fcd500a5..d7d4996b 100644 --- a/lib/l10n/generated/app_localizations_it.dart +++ b/lib/l10n/generated/app_localizations_it.dart @@ -19,6 +19,17 @@ class AppLocalizationsIt extends AppLocalizations { @override String get timeFormat => 'Formato ora'; + @override + String get firstDayOfWeek => 'Primo giorno della settimana'; + + @override + String get systemDefault => 'Predefinito di sistema'; + + @override + String systemDefaultResolved(String weekday) { + return 'Predefinito di sistema ($weekday)'; + } + @override String get timeFormatTwelveHour => '12 ore'; diff --git a/lib/l10n/generated/app_localizations_ja.dart b/lib/l10n/generated/app_localizations_ja.dart index 89b47b6a..89af0ce3 100644 --- a/lib/l10n/generated/app_localizations_ja.dart +++ b/lib/l10n/generated/app_localizations_ja.dart @@ -19,6 +19,17 @@ class AppLocalizationsJa extends AppLocalizations { @override String get timeFormat => '時刻の表示形式'; + @override + String get firstDayOfWeek => '週の最初の曜日'; + + @override + String get systemDefault => 'システムの既定値'; + + @override + String systemDefaultResolved(String weekday) { + return 'システムの既定値($weekday)'; + } + @override String get timeFormatTwelveHour => '12時間表示'; diff --git a/lib/l10n/generated/app_localizations_ko.dart b/lib/l10n/generated/app_localizations_ko.dart index 2833fa62..93698eed 100644 --- a/lib/l10n/generated/app_localizations_ko.dart +++ b/lib/l10n/generated/app_localizations_ko.dart @@ -19,6 +19,17 @@ class AppLocalizationsKo extends AppLocalizations { @override String get timeFormat => '시간 형식'; + @override + String get firstDayOfWeek => '한 주의 시작 요일'; + + @override + String get systemDefault => '시스템 기본값'; + + @override + String systemDefaultResolved(String weekday) { + return '시스템 기본값($weekday)'; + } + @override String get timeFormatTwelveHour => '12시간'; diff --git a/lib/l10n/generated/app_localizations_pl.dart b/lib/l10n/generated/app_localizations_pl.dart index 2dcee41f..6aad0970 100644 --- a/lib/l10n/generated/app_localizations_pl.dart +++ b/lib/l10n/generated/app_localizations_pl.dart @@ -19,6 +19,17 @@ class AppLocalizationsPl extends AppLocalizations { @override String get timeFormat => 'Format czasu'; + @override + String get firstDayOfWeek => 'Pierwszy dzień tygodnia'; + + @override + String get systemDefault => 'Domyślne ustawienie systemu'; + + @override + String systemDefaultResolved(String weekday) { + return 'Domyślne ustawienie systemu ($weekday)'; + } + @override String get timeFormatTwelveHour => '12-godzinny'; diff --git a/lib/l10n/generated/app_localizations_pt.dart b/lib/l10n/generated/app_localizations_pt.dart index 50058a64..9a8b698b 100644 --- a/lib/l10n/generated/app_localizations_pt.dart +++ b/lib/l10n/generated/app_localizations_pt.dart @@ -19,6 +19,17 @@ class AppLocalizationsPt extends AppLocalizations { @override String get timeFormat => 'Time format'; + @override + String get firstDayOfWeek => 'First day of the week'; + + @override + String get systemDefault => 'System default'; + + @override + String systemDefaultResolved(String weekday) { + return 'System default ($weekday)'; + } + @override String get timeFormatTwelveHour => '12-hour'; @@ -3018,6 +3029,17 @@ class AppLocalizationsPtPt extends AppLocalizationsPt { @override String get timeFormat => 'Formato da hora'; + @override + String get firstDayOfWeek => 'Primeiro dia da semana'; + + @override + String get systemDefault => 'Predefinição do sistema'; + + @override + String systemDefaultResolved(String weekday) { + return 'Predefinição do sistema ($weekday)'; + } + @override String get timeFormatTwelveHour => '12 horas'; diff --git a/lib/l10n/generated/app_localizations_ru.dart b/lib/l10n/generated/app_localizations_ru.dart index 7e4679a0..11ad24bc 100644 --- a/lib/l10n/generated/app_localizations_ru.dart +++ b/lib/l10n/generated/app_localizations_ru.dart @@ -19,6 +19,17 @@ class AppLocalizationsRu extends AppLocalizations { @override String get timeFormat => 'Формат времени'; + @override + String get firstDayOfWeek => 'Первый день недели'; + + @override + String get systemDefault => 'Системное значение'; + + @override + String systemDefaultResolved(String weekday) { + return 'Системное значение ($weekday)'; + } + @override String get timeFormatTwelveHour => '12 часов'; diff --git a/lib/l10n/generated/app_localizations_vi.dart b/lib/l10n/generated/app_localizations_vi.dart index 5d61a3e5..2d8f1e7c 100644 --- a/lib/l10n/generated/app_localizations_vi.dart +++ b/lib/l10n/generated/app_localizations_vi.dart @@ -19,6 +19,17 @@ class AppLocalizationsVi extends AppLocalizations { @override String get timeFormat => 'Định dạng giờ'; + @override + String get firstDayOfWeek => 'Ngày đầu tuần'; + + @override + String get systemDefault => 'Mặc định hệ thống'; + + @override + String systemDefaultResolved(String weekday) { + return 'Mặc định hệ thống ($weekday)'; + } + @override String get timeFormatTwelveHour => '12 giờ'; diff --git a/lib/l10n/generated/app_localizations_zh.dart b/lib/l10n/generated/app_localizations_zh.dart index 67563baf..f2f6e30f 100644 --- a/lib/l10n/generated/app_localizations_zh.dart +++ b/lib/l10n/generated/app_localizations_zh.dart @@ -19,6 +19,17 @@ class AppLocalizationsZh extends AppLocalizations { @override String get timeFormat => '时间格式'; + @override + String get firstDayOfWeek => '一周的第一天'; + + @override + String get systemDefault => '系统默认'; + + @override + String systemDefaultResolved(String weekday) { + return '系统默认($weekday)'; + } + @override String get timeFormatTwelveHour => '12小时制'; @@ -2886,6 +2897,17 @@ class AppLocalizationsZhHans extends AppLocalizationsZh { @override String get timeFormat => '时间格式'; + @override + String get firstDayOfWeek => '一周的第一天'; + + @override + String get systemDefault => '系统默认'; + + @override + String systemDefaultResolved(String weekday) { + return '系统默认($weekday)'; + } + @override String get timeFormatTwelveHour => '12小时制'; @@ -5753,6 +5775,17 @@ class AppLocalizationsZhHant extends AppLocalizationsZh { @override String get timeFormat => '時間格式'; + @override + String get firstDayOfWeek => '一週的第一天'; + + @override + String get systemDefault => '系統預設'; + + @override + String systemDefaultResolved(String weekday) { + return '系統預設($weekday)'; + } + @override String get timeFormatTwelveHour => '12小時制'; diff --git a/lib/src/android/android_app.dart b/lib/src/android/android_app.dart index 4417b070..d10ad85c 100644 --- a/lib/src/android/android_app.dart +++ b/lib/src/android/android_app.dart @@ -12,6 +12,8 @@ import '../l10n/app_locale.dart'; import '../l10n/locale_resolution.dart'; import '../l10n/l10n.dart'; import '../l10n/time_format_scope.dart'; +import '../l10n/week_preferences_scope.dart'; +import '../platform/android/android_first_weekday_source.dart'; import 'android_notifications.dart'; import 'presentation/android_schedule_screen.dart'; import 'presentation/android_settings_screen.dart'; @@ -19,11 +21,38 @@ import 'presentation/android_tasks_screen.dart'; final androidSelectedDestinationProvider = StateProvider((ref) => 0); -class AndroidBusyMaxApp extends ConsumerWidget { +class AndroidBusyMaxApp extends ConsumerStatefulWidget { const AndroidBusyMaxApp({super.key}); @override - Widget build(BuildContext context, WidgetRef ref) { + ConsumerState createState() => _AndroidBusyMaxAppState(); +} + +class _AndroidBusyMaxAppState extends ConsumerState { + late final BusyMaxSystemFirstWeekdayController _firstWeekdayController; + + @override + void initState() { + super.initState(); + _firstWeekdayController = BusyMaxSystemFirstWeekdayController( + AndroidFirstWeekdaySource(), + )..addListener(_platformChanged); + } + + void _platformChanged() { + if (mounted) setState(() {}); + } + + @override + void dispose() { + _firstWeekdayController + ..removeListener(_platformChanged) + ..dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { final settings = ref.watch(appSettingsControllerProvider); return MaterialApp( restorationScopeId: 'busymax_android', @@ -50,7 +79,13 @@ class AndroidBusyMaxApp extends ConsumerWidget { systemUses24Hour: ref.watch(androidSystemUses24HourProvider), ), ), - child: child ?? const SizedBox.shrink(), + child: BusyMaxWeekPreferencesScope( + preference: settings.firstDayOfWeekPreference, + systemWeekday: _firstWeekdayController.value, + platformLocaleTag: WidgetsBinding.instance.platformDispatcher.locale + .toLanguageTag(), + child: child ?? const SizedBox.shrink(), + ), ), home: const AndroidHomeShell(), ); diff --git a/lib/src/android/presentation/android_date_picker.dart b/lib/src/android/presentation/android_date_picker.dart new file mode 100644 index 00000000..222ad209 --- /dev/null +++ b/lib/src/android/presentation/android_date_picker.dart @@ -0,0 +1,422 @@ +// ignore_for_file: deprecated_member_use + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +import '../../l10n/week_preferences_scope.dart'; + +Future showBusyMaxDatePicker({ + required BuildContext context, + required DateTime initialDate, + required DateTime firstDate, + required DateTime lastDate, + DateTime? currentDate, + DatePickerEntryMode initialEntryMode = DatePickerEntryMode.calendar, + SelectableDayPredicate? selectableDayPredicate, + String? helpText, + String? cancelText, + String? confirmText, + DatePickerMode initialDatePickerMode = DatePickerMode.day, + String? errorFormatText, + String? errorInvalidText, + String? fieldHintText, + String? fieldLabelText, + TextInputType? keyboardType, + ValueChanged? onDatePickerModeChange, + Icon? switchToInputEntryModeIcon, + Icon? switchToCalendarEntryModeIcon, + bool barrierDismissible = true, + Color? barrierColor, + String? barrierLabel, + bool useRootNavigator = true, + RouteSettings? routeSettings, + TextDirection? textDirection, + TransitionBuilder? builder, + Offset? anchorPoint, +}) => showDatePicker( + context: context, + initialDate: initialDate, + firstDate: firstDate, + lastDate: lastDate, + currentDate: currentDate, + initialEntryMode: initialEntryMode, + selectableDayPredicate: selectableDayPredicate, + helpText: helpText, + cancelText: cancelText, + confirmText: confirmText, + initialDatePickerMode: initialDatePickerMode, + errorFormatText: errorFormatText, + errorInvalidText: errorInvalidText, + fieldHintText: fieldHintText, + fieldLabelText: fieldLabelText, + keyboardType: keyboardType, + onDatePickerModeChange: onDatePickerModeChange, + switchToInputEntryModeIcon: switchToInputEntryModeIcon, + switchToCalendarEntryModeIcon: switchToCalendarEntryModeIcon, + barrierDismissible: barrierDismissible, + barrierColor: barrierColor, + barrierLabel: barrierLabel, + useRootNavigator: useRootNavigator, + routeSettings: routeSettings, + textDirection: textDirection, + anchorPoint: anchorPoint, + builder: (pickerContext, child) { + final actual = MaterialLocalizations.of(pickerContext); + final firstWeekday = + BusyMaxWeekPreferencesScope.firstWeekdayOf(pickerContext); + final localizedChild = Localizations.override( + context: pickerContext, + delegates: [ + _BusyMaxPickerLocalizationsDelegate(actual, firstWeekday), + ], + child: child!, + ); + return builder?.call(pickerContext, localizedChild) ?? localizedChild; + }, +); + +class _BusyMaxPickerLocalizationsDelegate + extends LocalizationsDelegate { + const _BusyMaxPickerLocalizationsDelegate(this.actual, this.firstWeekday); + + final MaterialLocalizations actual; + final int firstWeekday; + + @override + bool isSupported(Locale locale) => true; + + @override + Future load(Locale locale) => + SynchronousFuture( + _BusyMaxPickerMaterialLocalizations(actual, firstWeekday), + ); + + @override + bool shouldReload(_BusyMaxPickerLocalizationsDelegate old) => + !identical(old.actual, actual) || old.firstWeekday != firstWeekday; +} + +class _BusyMaxPickerMaterialLocalizations implements MaterialLocalizations { + const _BusyMaxPickerMaterialLocalizations( + this._delegate, + this._firstWeekday, + ); + + final MaterialLocalizations _delegate; + final int _firstWeekday; + + @override + String get openAppDrawerTooltip => _delegate.openAppDrawerTooltip; + @override + String get backButtonTooltip => _delegate.backButtonTooltip; + @override + String get clearButtonTooltip => _delegate.clearButtonTooltip; + @override + String get closeButtonTooltip => _delegate.closeButtonTooltip; + @override + String get deleteButtonTooltip => _delegate.deleteButtonTooltip; + @override + String get moreButtonTooltip => _delegate.moreButtonTooltip; + @override + String get nextMonthTooltip => _delegate.nextMonthTooltip; + @override + String get previousMonthTooltip => _delegate.previousMonthTooltip; + @override + String get firstPageTooltip => _delegate.firstPageTooltip; + @override + String get lastPageTooltip => _delegate.lastPageTooltip; + @override + String get nextPageTooltip => _delegate.nextPageTooltip; + @override + String get previousPageTooltip => _delegate.previousPageTooltip; + @override + String get showMenuTooltip => _delegate.showMenuTooltip; + @override + String aboutListTileTitle(String applicationName) => _delegate.aboutListTileTitle(applicationName); + @override + String get licensesPageTitle => _delegate.licensesPageTitle; + @override + String licensesPackageDetailText(int licenseCount) => _delegate.licensesPackageDetailText(licenseCount); + @override + String pageRowsInfoTitle(int firstRow, int lastRow, int rowCount, bool rowCountIsApproximate) => _delegate.pageRowsInfoTitle(firstRow, lastRow, rowCount, rowCountIsApproximate); + @override + String get rowsPerPageTitle => _delegate.rowsPerPageTitle; + @override + String tabLabel({required int tabIndex, required int tabCount}) => _delegate.tabLabel(tabIndex: tabIndex, tabCount: tabCount); + @override + String selectedRowCountTitle(int selectedRowCount) => _delegate.selectedRowCountTitle(selectedRowCount); + @override + String get cancelButtonLabel => _delegate.cancelButtonLabel; + @override + String get closeButtonLabel => _delegate.closeButtonLabel; + @override + String get continueButtonLabel => _delegate.continueButtonLabel; + @override + String get copyButtonLabel => _delegate.copyButtonLabel; + @override + String get cutButtonLabel => _delegate.cutButtonLabel; + @override + String get scanTextButtonLabel => _delegate.scanTextButtonLabel; + @override + String get okButtonLabel => _delegate.okButtonLabel; + @override + String get pasteButtonLabel => _delegate.pasteButtonLabel; + @override + String get selectAllButtonLabel => _delegate.selectAllButtonLabel; + @override + String get lookUpButtonLabel => _delegate.lookUpButtonLabel; + @override + String get searchWebButtonLabel => _delegate.searchWebButtonLabel; + @override + String get shareButtonLabel => _delegate.shareButtonLabel; + @override + String get viewLicensesButtonLabel => _delegate.viewLicensesButtonLabel; + @override + String get anteMeridiemAbbreviation => _delegate.anteMeridiemAbbreviation; + @override + String get postMeridiemAbbreviation => _delegate.postMeridiemAbbreviation; + @override + String get timePickerHourModeAnnouncement => _delegate.timePickerHourModeAnnouncement; + @override + String get timePickerMinuteModeAnnouncement => _delegate.timePickerMinuteModeAnnouncement; + @override + String get modalBarrierDismissLabel => _delegate.modalBarrierDismissLabel; + @override + String get menuDismissLabel => _delegate.menuDismissLabel; + @override + String get drawerLabel => _delegate.drawerLabel; + @override + String get popupMenuLabel => _delegate.popupMenuLabel; + @override + String get menuBarMenuLabel => _delegate.menuBarMenuLabel; + @override + String get dialogLabel => _delegate.dialogLabel; + @override + String get alertDialogLabel => _delegate.alertDialogLabel; + @override + String get searchFieldLabel => _delegate.searchFieldLabel; + @override + String get currentDateLabel => _delegate.currentDateLabel; + @override + String get selectedDateLabel => _delegate.selectedDateLabel; + @override + String get scrimLabel => _delegate.scrimLabel; + @override + String get bottomSheetLabel => _delegate.bottomSheetLabel; + @override + String scrimOnTapHint(String modalRouteContentName) => _delegate.scrimOnTapHint(modalRouteContentName); + @override + TimeOfDayFormat timeOfDayFormat({bool alwaysUse24HourFormat = false}) => _delegate.timeOfDayFormat(alwaysUse24HourFormat: alwaysUse24HourFormat); + @override + ScriptCategory get scriptCategory => _delegate.scriptCategory; + @override + String formatDecimal(int number) => _delegate.formatDecimal(number); + @override + String formatHour(TimeOfDay timeOfDay, {bool alwaysUse24HourFormat = false}) => _delegate.formatHour(timeOfDay, alwaysUse24HourFormat: alwaysUse24HourFormat); + @override + String formatMinute(TimeOfDay timeOfDay) => _delegate.formatMinute(timeOfDay); + @override + String formatTimeOfDay(TimeOfDay timeOfDay, {bool alwaysUse24HourFormat = false}) => _delegate.formatTimeOfDay(timeOfDay, alwaysUse24HourFormat: alwaysUse24HourFormat); + @override + String formatYear(DateTime date) => _delegate.formatYear(date); + @override + String formatCompactDate(DateTime date) => _delegate.formatCompactDate(date); + @override + String formatShortDate(DateTime date) => _delegate.formatShortDate(date); + @override + String formatMediumDate(DateTime date) => _delegate.formatMediumDate(date); + @override + String formatFullDate(DateTime date) => _delegate.formatFullDate(date); + @override + String formatMonthYear(DateTime date) => _delegate.formatMonthYear(date); + @override + String formatShortMonthDay(DateTime date) => _delegate.formatShortMonthDay(date); + @override + DateTime? parseCompactDate(String? inputString) => _delegate.parseCompactDate(inputString); + @override + List get narrowWeekdays => _delegate.narrowWeekdays; + @override + int get firstDayOfWeekIndex => _firstWeekday == DateTime.sunday ? 0 : _firstWeekday; + @override + String get dateSeparator => _delegate.dateSeparator; + @override + String get dateHelpText => _delegate.dateHelpText; + @override + String get selectYearSemanticsLabel => _delegate.selectYearSemanticsLabel; + @override + String get unspecifiedDate => _delegate.unspecifiedDate; + @override + String get unspecifiedDateRange => _delegate.unspecifiedDateRange; + @override + String get dateInputLabel => _delegate.dateInputLabel; + @override + String get dateRangeStartLabel => _delegate.dateRangeStartLabel; + @override + String get dateRangeEndLabel => _delegate.dateRangeEndLabel; + @override + String dateRangeStartDateSemanticLabel(String formattedDate) => _delegate.dateRangeStartDateSemanticLabel(formattedDate); + @override + String dateRangeEndDateSemanticLabel(String formattedDate) => _delegate.dateRangeEndDateSemanticLabel(formattedDate); + @override + String get invalidDateFormatLabel => _delegate.invalidDateFormatLabel; + @override + String get invalidDateRangeLabel => _delegate.invalidDateRangeLabel; + @override + String get dateOutOfRangeLabel => _delegate.dateOutOfRangeLabel; + @override + String get saveButtonLabel => _delegate.saveButtonLabel; + @override + String get datePickerHelpText => _delegate.datePickerHelpText; + @override + String get dateRangePickerHelpText => _delegate.dateRangePickerHelpText; + @override + String get calendarModeButtonLabel => _delegate.calendarModeButtonLabel; + @override + String get inputDateModeButtonLabel => _delegate.inputDateModeButtonLabel; + @override + String get timePickerDialHelpText => _delegate.timePickerDialHelpText; + @override + String get timePickerInputHelpText => _delegate.timePickerInputHelpText; + @override + String get timePickerHourLabel => _delegate.timePickerHourLabel; + @override + String get timePickerMinuteLabel => _delegate.timePickerMinuteLabel; + @override + String get invalidTimeLabel => _delegate.invalidTimeLabel; + @override + String get dialModeButtonLabel => _delegate.dialModeButtonLabel; + @override + String get inputTimeModeButtonLabel => _delegate.inputTimeModeButtonLabel; + @override + String get signedInLabel => _delegate.signedInLabel; + @override + String get hideAccountsLabel => _delegate.hideAccountsLabel; + @override + String get showAccountsLabel => _delegate.showAccountsLabel; + @override + String get reorderItemToStart => _delegate.reorderItemToStart; + @override + String get reorderItemToEnd => _delegate.reorderItemToEnd; + @override + String get reorderItemUp => _delegate.reorderItemUp; + @override + String get reorderItemDown => _delegate.reorderItemDown; + @override + String get reorderItemLeft => _delegate.reorderItemLeft; + @override + String get reorderItemRight => _delegate.reorderItemRight; + @override + String get expandedIconTapHint => _delegate.expandedIconTapHint; + @override + String get collapsedIconTapHint => _delegate.collapsedIconTapHint; + @override + String get expansionTileExpandedHint => _delegate.expansionTileExpandedHint; + @override + String get expansionTileCollapsedHint => _delegate.expansionTileCollapsedHint; + @override + String get expansionTileExpandedTapHint => _delegate.expansionTileExpandedTapHint; + @override + String get expansionTileCollapsedTapHint => _delegate.expansionTileCollapsedTapHint; + @override + String get expandedHint => _delegate.expandedHint; + @override + String get collapsedHint => _delegate.collapsedHint; + @override + String remainingTextFieldCharacterCount(int remaining) => _delegate.remainingTextFieldCharacterCount(remaining); + @override + String get refreshIndicatorSemanticLabel => _delegate.refreshIndicatorSemanticLabel; + @override + String get keyboardKeyAlt => _delegate.keyboardKeyAlt; + @override + String get keyboardKeyAltGraph => _delegate.keyboardKeyAltGraph; + @override + String get keyboardKeyBackspace => _delegate.keyboardKeyBackspace; + @override + String get keyboardKeyCapsLock => _delegate.keyboardKeyCapsLock; + @override + String get keyboardKeyChannelDown => _delegate.keyboardKeyChannelDown; + @override + String get keyboardKeyChannelUp => _delegate.keyboardKeyChannelUp; + @override + String get keyboardKeyControl => _delegate.keyboardKeyControl; + @override + String get keyboardKeyDelete => _delegate.keyboardKeyDelete; + @override + String get keyboardKeyEject => _delegate.keyboardKeyEject; + @override + String get keyboardKeyEnd => _delegate.keyboardKeyEnd; + @override + String get keyboardKeyEscape => _delegate.keyboardKeyEscape; + @override + String get keyboardKeyFn => _delegate.keyboardKeyFn; + @override + String get keyboardKeyHome => _delegate.keyboardKeyHome; + @override + String get keyboardKeyInsert => _delegate.keyboardKeyInsert; + @override + String get keyboardKeyMeta => _delegate.keyboardKeyMeta; + @override + String get keyboardKeyMetaMacOs => _delegate.keyboardKeyMetaMacOs; + @override + String get keyboardKeyMetaWindows => _delegate.keyboardKeyMetaWindows; + @override + String get keyboardKeyNumLock => _delegate.keyboardKeyNumLock; + @override + String get keyboardKeyNumpad1 => _delegate.keyboardKeyNumpad1; + @override + String get keyboardKeyNumpad2 => _delegate.keyboardKeyNumpad2; + @override + String get keyboardKeyNumpad3 => _delegate.keyboardKeyNumpad3; + @override + String get keyboardKeyNumpad4 => _delegate.keyboardKeyNumpad4; + @override + String get keyboardKeyNumpad5 => _delegate.keyboardKeyNumpad5; + @override + String get keyboardKeyNumpad6 => _delegate.keyboardKeyNumpad6; + @override + String get keyboardKeyNumpad7 => _delegate.keyboardKeyNumpad7; + @override + String get keyboardKeyNumpad8 => _delegate.keyboardKeyNumpad8; + @override + String get keyboardKeyNumpad9 => _delegate.keyboardKeyNumpad9; + @override + String get keyboardKeyNumpad0 => _delegate.keyboardKeyNumpad0; + @override + String get keyboardKeyNumpadAdd => _delegate.keyboardKeyNumpadAdd; + @override + String get keyboardKeyNumpadComma => _delegate.keyboardKeyNumpadComma; + @override + String get keyboardKeyNumpadDecimal => _delegate.keyboardKeyNumpadDecimal; + @override + String get keyboardKeyNumpadDivide => _delegate.keyboardKeyNumpadDivide; + @override + String get keyboardKeyNumpadEnter => _delegate.keyboardKeyNumpadEnter; + @override + String get keyboardKeyNumpadEqual => _delegate.keyboardKeyNumpadEqual; + @override + String get keyboardKeyNumpadMultiply => _delegate.keyboardKeyNumpadMultiply; + @override + String get keyboardKeyNumpadParenLeft => _delegate.keyboardKeyNumpadParenLeft; + @override + String get keyboardKeyNumpadParenRight => _delegate.keyboardKeyNumpadParenRight; + @override + String get keyboardKeyNumpadSubtract => _delegate.keyboardKeyNumpadSubtract; + @override + String get keyboardKeyPageDown => _delegate.keyboardKeyPageDown; + @override + String get keyboardKeyPageUp => _delegate.keyboardKeyPageUp; + @override + String get keyboardKeyPower => _delegate.keyboardKeyPower; + @override + String get keyboardKeyPowerOff => _delegate.keyboardKeyPowerOff; + @override + String get keyboardKeyPrintScreen => _delegate.keyboardKeyPrintScreen; + @override + String get keyboardKeyScrollLock => _delegate.keyboardKeyScrollLock; + @override + String get keyboardKeySelect => _delegate.keyboardKeySelect; + @override + String get keyboardKeyShift => _delegate.keyboardKeyShift; + @override + String get keyboardKeySpace => _delegate.keyboardKeySpace; +} diff --git a/lib/src/android/presentation/android_schedule_screen.dart b/lib/src/android/presentation/android_schedule_screen.dart index 1db18ee8..14d2d0f2 100644 --- a/lib/src/android/presentation/android_schedule_screen.dart +++ b/lib/src/android/presentation/android_schedule_screen.dart @@ -20,6 +20,7 @@ import '../../features/recurrence/domain/event_recurrence_codec.dart'; import '../../features/recurrence/domain/recurrence_rule.dart'; import '../../l10n/l10n.dart'; import '../../l10n/time_format_scope.dart'; +import '../../l10n/week_preferences_scope.dart'; import '../../providers/busy_provider.dart'; import '../../schedule/schedule_filters.dart'; import '../../schedule/schedule_item.dart'; @@ -32,6 +33,7 @@ import 'android_schedule_search_filters.dart'; import '../../features/schedule/presentation/schedule_search_result_text.dart'; import '../android_notifications.dart'; import 'android_availability_dialog.dart'; +import 'android_date_picker.dart'; import 'android_settings_screen.dart'; import 'android_tasks_screen.dart'; @@ -98,6 +100,19 @@ class _AndroidScheduleScreenState extends ConsumerState { final _searchController = TextEditingController(); ScheduleSearchCriteria? _searchCriteria; ScheduleSearchCriteria? _initialSearchCriteria; + var _searchFirstWeekday = DateTime.monday; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final firstWeekday = _firstWeekday(context); + if (_searchFirstWeekday == firstWeekday) return; + _searchFirstWeekday = firstWeekday; + _searchCriteria = _searchCriteria?.copyWith(firstWeekday: firstWeekday); + _initialSearchCriteria = _initialSearchCriteria?.copyWith( + firstWeekday: firstWeekday, + ); + } @override void dispose() { @@ -428,7 +443,7 @@ class _AndroidScheduleScreenState extends ConsumerState { } Future _selectDate() async { - final selected = await showDatePicker( + final selected = await showBusyMaxDatePicker( context: context, initialDate: _anchor, firstDate: DateTime(1900), @@ -2576,7 +2591,7 @@ class _DateTimeTile extends StatelessWidget { onTap: onChanged == null ? null : () async { - final date = await showDatePicker( + final date = await showBusyMaxDatePicker( context: context, initialDate: value, firstDate: DateTime(1970), @@ -2731,8 +2746,7 @@ bool _sameDay(DateTime a, DateTime b) => a.year == b.year && a.month == b.month && a.day == b.day; int _firstWeekday(BuildContext context) { - final index = MaterialLocalizations.of(context).firstDayOfWeekIndex; - return index == 0 ? DateTime.sunday : index; + return BusyMaxWeekPreferencesScope.firstWeekdayOf(context); } Object? _eventReminders(BusyProvider provider, int minutes) { diff --git a/lib/src/android/presentation/android_schedule_search_filters.dart b/lib/src/android/presentation/android_schedule_search_filters.dart index 41775cad..215c3f1d 100644 --- a/lib/src/android/presentation/android_schedule_search_filters.dart +++ b/lib/src/android/presentation/android_schedule_search_filters.dart @@ -8,6 +8,7 @@ import '../../features/accounts/data/accounts_repository.dart'; import '../../features/calendar/data/calendar_repository.dart'; import '../../features/task_lists/data/task_lists_repository.dart'; import '../../features/schedule/presentation/schedule_search_labels.dart'; +import 'android_date_picker.dart'; /// Material search controls for the Android bottom sheet. /// This widget only emits immutable criteria; it never writes source settings. @@ -200,7 +201,7 @@ class AndroidScheduleSearchFilters extends StatelessWidget { padding: const EdgeInsetsDirectional.only(bottom: 8), child: OutlinedButton( onPressed: () async { - final picked = await showDatePicker( + final picked = await showBusyMaxDatePicker( context: context, initialDate: date, firstDate: DateTime(1900), diff --git a/lib/src/android/presentation/android_settings_screen.dart b/lib/src/android/presentation/android_settings_screen.dart index 1d1dadf4..6da02e64 100644 --- a/lib/src/android/presentation/android_settings_screen.dart +++ b/lib/src/android/presentation/android_settings_screen.dart @@ -25,6 +25,7 @@ import '../../ical/ical_import_service.dart'; import '../../ical/ical_ingestion.dart'; import '../../l10n/app_locale.dart'; import '../../l10n/l10n.dart'; +import '../../l10n/week_preferences_scope.dart'; import '../../providers/busy_provider.dart'; import '../../webcal/webcal_subscription_service.dart'; import '../android_background.dart'; @@ -339,6 +340,39 @@ class _AndroidSettingsScreenState extends ConsumerState { ], ), ), + ListTile( + leading: const Icon(Icons.calendar_view_week_outlined), + title: Text(context.l10n.firstDayOfWeek), + subtitle: DropdownButtonHideUnderline( + child: DropdownButton( + isExpanded: true, + value: settings.firstDayOfWeekPreference, + onChanged: (value) { + if (value != null) { + unawaited( + ref + .read(appSettingsControllerProvider.notifier) + .setFirstDayOfWeekPreference(value), + ); + } + }, + items: [ + for (final value + in BusyMaxFirstDayOfWeekPreference.values) + DropdownMenuItem( + value: value, + child: Text( + busyMaxFirstDayOfWeekPreferenceLabel( + context, + value, + ), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + ), ], ), _Section( diff --git a/lib/src/android/presentation/android_tasks_screen.dart b/lib/src/android/presentation/android_tasks_screen.dart index 6de16b46..95813d7d 100644 --- a/lib/src/android/presentation/android_tasks_screen.dart +++ b/lib/src/android/presentation/android_tasks_screen.dart @@ -21,6 +21,7 @@ import '../../providers/busy_provider.dart'; import '../../schedule/schedule_filters.dart'; import '../../schedule/schedule_item.dart'; import '../android_notifications.dart'; +import 'android_date_picker.dart'; final _androidTasksProvider = FutureProvider.autoDispose .family< @@ -1030,7 +1031,7 @@ class _AndroidTaskEditorState extends ConsumerState { Future _pickDue() async { final current = DateTime.tryParse(_draft.dueDate ?? '') ?? DateTime.now(); - final value = await showDatePicker( + final value = await showBusyMaxDatePicker( context: context, initialDate: current, firstDate: DateTime(1970), @@ -1205,7 +1206,7 @@ class _AndroidTaskEditorState extends ConsumerState { Future _pickStartDate() async { final current = DateTime.tryParse(_draft.microsoftStartDate ?? '') ?? DateTime.now(); - final value = await showDatePicker( + final value = await showBusyMaxDatePicker( context: context, initialDate: current, firstDate: DateTime(1970), @@ -1228,7 +1229,7 @@ class _AndroidTaskEditorState extends ConsumerState { Future _pickReminderDate() async { final current = DateTime.tryParse(_draft.microsoftReminderDate ?? '') ?? DateTime.now(); - final value = await showDatePicker( + final value = await showBusyMaxDatePicker( context: context, initialDate: current, firstDate: DateTime(1970), @@ -1325,7 +1326,7 @@ class _AndroidTaskEditorState extends ConsumerState { Future _pickCompletionDate() async { final current = DateTime.tryParse(_draft.completedDate ?? '') ?? DateTime.now(); - final value = await showDatePicker( + final value = await showBusyMaxDatePicker( context: context, initialDate: current, firstDate: DateTime(1970), @@ -1394,7 +1395,7 @@ class _AndroidTaskEditorState extends ConsumerState { if (mode == null || !mounted) return; IcalTaskAlarm? alarm; if (mode == 'absolute') { - final date = await showDatePicker( + final date = await showBusyMaxDatePicker( context: context, initialDate: DateTime.now(), firstDate: DateTime(1970), @@ -2395,7 +2396,7 @@ class _AndroidRecurrenceDialogState extends State<_AndroidRecurrenceDialog> { } Future _pickUntil() async { - final value = await showDatePicker( + final value = await showBusyMaxDatePicker( context: context, initialDate: _until, firstDate: DateTime( diff --git a/lib/src/app/app_settings.dart b/lib/src/app/app_settings.dart index 1f4c50df..f0c40526 100644 --- a/lib/src/app/app_settings.dart +++ b/lib/src/app/app_settings.dart @@ -10,8 +10,10 @@ import 'package:path/path.dart' as p; import 'package:path_provider/path_provider.dart'; import '../l10n/app_locale.dart'; +import '../l10n/first_day_of_week.dart'; import '../l10n/time_format.dart'; +export '../l10n/first_day_of_week.dart' show BusyMaxFirstDayOfWeekPreference; export '../l10n/time_format.dart' show BusyMaxTimeFormatPreference; import '../core/logging/redacting_logger.dart'; import '../schedule/schedule_sidebar_order.dart'; @@ -43,6 +45,7 @@ class AppSettings { required this.themeModePreference, required this.localeTag, this.timeFormatPreference = BusyMaxTimeFormatPreference.system, + this.firstDayOfWeekPreference = BusyMaxFirstDayOfWeekPreference.system, required this.notifySyncFailures, required this.notifyConflicts, required this.notifyDueToday, @@ -72,6 +75,7 @@ class AppSettings { themeModePreference: BusyMaxThemeModePreference.system, localeTag: null, timeFormatPreference: BusyMaxTimeFormatPreference.system, + firstDayOfWeekPreference: BusyMaxFirstDayOfWeekPreference.system, notifySyncFailures: true, notifyConflicts: true, notifyDueToday: false, @@ -163,6 +167,11 @@ class AppSettings { json['timeFormatPreference'], defaults.timeFormatPreference, ), + firstDayOfWeekPreference: _enumFromName( + BusyMaxFirstDayOfWeekPreference.values, + json['firstDayOfWeekPreference'], + defaults.firstDayOfWeekPreference, + ), notifySyncFailures: json['notifySyncFailures'] as bool? ?? defaults.notifySyncFailures, notifyConflicts: @@ -209,6 +218,7 @@ class AppSettings { final BusyMaxThemeModePreference themeModePreference; final String? localeTag; final BusyMaxTimeFormatPreference timeFormatPreference; + final BusyMaxFirstDayOfWeekPreference firstDayOfWeekPreference; final bool notifySyncFailures; final bool notifyConflicts; final bool notifyDueToday; @@ -241,6 +251,7 @@ class AppSettings { 'themeModePreference': themeModePreference.name, 'localeTag': localeTag, 'timeFormatPreference': timeFormatPreference.name, + 'firstDayOfWeekPreference': firstDayOfWeekPreference.name, 'notifySyncFailures': notifySyncFailures, 'notifyConflicts': notifyConflicts, 'notifyDueToday': notifyDueToday, @@ -270,6 +281,7 @@ class AppSettings { BusyMaxThemeModePreference? themeModePreference, Object? localeTag = _unset, BusyMaxTimeFormatPreference? timeFormatPreference, + BusyMaxFirstDayOfWeekPreference? firstDayOfWeekPreference, bool? notifySyncFailures, bool? notifyConflicts, bool? notifyDueToday, @@ -305,6 +317,8 @@ class AppSettings { return AppSettings( themeFamily: themeFamily ?? this.themeFamily, timeFormatPreference: timeFormatPreference ?? this.timeFormatPreference, + firstDayOfWeekPreference: + firstDayOfWeekPreference ?? this.firstDayOfWeekPreference, themeModePreference: themeModePreference ?? this.themeModePreference, localeTag: identical(localeTag, _unset) ? this.localeTag @@ -422,6 +436,10 @@ class AppSettingsController extends StateNotifier { Future setTimeFormatPreference(BusyMaxTimeFormatPreference value) => _mutate((current) => current.copyWith(timeFormatPreference: value)); + Future setFirstDayOfWeekPreference( + BusyMaxFirstDayOfWeekPreference value, + ) => _mutate((current) => current.copyWith(firstDayOfWeekPreference: value)); + Future registerSidebarIds( SidebarOrderSection section, Iterable ids, { diff --git a/lib/src/app/busymax_app.dart b/lib/src/app/busymax_app.dart index a5078ce0..4cf3d9ad 100644 --- a/lib/src/app/busymax_app.dart +++ b/lib/src/app/busymax_app.dart @@ -9,12 +9,14 @@ import '../platform/busymax_tray_service.dart'; import '../features/tray/domain/tray_presentation.dart'; import '../features/tray/domain/tray_presentation_formatter.dart'; import '../platform/gtk_font_service.dart'; +import '../platform/linux_first_weekday_source.dart'; import '../platform/linux_header_bar_configuration_synchronizer.dart'; import '../platform/linux_header_bar_provider.dart'; import '../platform/linux_header_bar_service.dart'; import '../platform/common/desktop_services.dart'; import '../l10n/locale_resolution.dart'; import '../l10n/time_format_scope.dart'; +import '../l10n/week_preferences_scope.dart'; import '../schedule/schedule_commands.dart'; import 'app_bootstrap.dart'; import 'desktop_startup_policy.dart'; @@ -87,6 +89,7 @@ class BusyMaxApp extends LinuxBusyMaxApp { } class _BusyMaxAppState extends ConsumerState { + late final BusyMaxSystemFirstWeekdayController _firstWeekdayController; BusyMaxTrayService? _trayService; bool? _lastHideOnClose; bool? _lastTrayEnabled; @@ -107,6 +110,9 @@ class _BusyMaxAppState extends ConsumerState { @override void initState() { super.initState(); + _firstWeekdayController = BusyMaxSystemFirstWeekdayController( + const LinuxFirstWeekdaySource(), + )..addListener(_weekPreferenceChanged); _headerBarConfigurationSynchronizer = BusyMaxHeaderBarConfigurationSynchronizer( ref.read(linuxHeaderBarServiceProvider), @@ -128,6 +134,9 @@ class _BusyMaxAppState extends ConsumerState { @override void dispose() { + _firstWeekdayController + ..removeListener(_weekPreferenceChanged) + ..dispose(); _headerBarConfigurationSynchronizer.dispose(); unawaited(_externalOpenSubscription?.cancel()); unawaited(_navigationSubscription?.cancel()); @@ -138,6 +147,10 @@ class _BusyMaxAppState extends ConsumerState { super.dispose(); } + void _weekPreferenceChanged() { + if (mounted) setState(() {}); + } + Future _handleExternalCalendarOpen(DesktopActivation request) async { if (!mounted) return; final router = ref.read(appRouterProvider); @@ -335,11 +348,20 @@ class _BusyMaxAppState extends ConsumerState { color: BusyMaxSurfaceColors.of(context).window, child: BusyMaxTimeFormatScope( formatter: clock, - child: MediaQuery( - data: MediaQuery.of( - context, - ).copyWith(alwaysUse24HourFormat: clock.use24Hour), - child: child ?? const SizedBox.shrink(), + child: BusyMaxWeekPreferencesScope( + preference: settings.firstDayOfWeekPreference, + systemWeekday: _firstWeekdayController.value, + platformLocaleTag: WidgetsBinding + .instance + .platformDispatcher + .locale + .toLanguageTag(), + child: MediaQuery( + data: MediaQuery.of( + context, + ).copyWith(alwaysUse24HourFormat: clock.use24Hour), + child: child ?? const SizedBox.shrink(), + ), ), ), ), diff --git a/lib/src/app/windows/windows_busymax_app.dart b/lib/src/app/windows/windows_busymax_app.dart index dde90afa..33b07f81 100644 --- a/lib/src/app/windows/windows_busymax_app.dart +++ b/lib/src/app/windows/windows_busymax_app.dart @@ -8,7 +8,9 @@ import '../../../l10n/generated/app_localizations.dart'; import '../../features/auth/data/auth_repository.dart'; import '../../l10n/locale_resolution.dart'; import '../../l10n/time_format_scope.dart'; +import '../../l10n/week_preferences_scope.dart'; import '../../platform/windows/windows_clock_preference.dart'; +import '../../platform/windows/windows_first_weekday_source.dart'; import '../../platform/common/desktop_services.dart'; import '../../ui/windows/windows_schedule_page.dart'; import '../../ui/windows/windows_settings_page.dart'; @@ -71,12 +73,16 @@ class _WindowsBusyMaxAppState extends ConsumerState StreamSubscription? _navigationSubscription; StreamSubscription? _appearanceSubscription; late final WindowsClockPreference _clockPreference; + late final BusyMaxSystemFirstWeekdayController _firstWeekdayController; @override void initState() { super.initState(); WidgetsBinding.instance.addObserver(this); _clockPreference = WindowsClockPreference()..addListener(_platformChanged); + _firstWeekdayController = BusyMaxSystemFirstWeekdayController( + WindowsFirstWeekdaySource(), + )..addListener(_platformChanged); _activationSubscription = ref .read(desktopActivationServiceProvider) .activations @@ -168,6 +174,9 @@ class _WindowsBusyMaxAppState extends ConsumerState void dispose() { WidgetsBinding.instance.removeObserver(this); _clockPreference.dispose(); + _firstWeekdayController + ..removeListener(_platformChanged) + ..dispose(); unawaited(_activationSubscription?.cancel()); unawaited(_navigationSubscription?.cancel()); unawaited(_appearanceSubscription?.cancel()); @@ -233,13 +242,19 @@ class _WindowsBusyMaxAppState extends ConsumerState ); return BusyMaxTimeFormatScope( formatter: clock, - child: MediaQuery( - data: MediaQuery.of( - context, - ).copyWith(alwaysUse24HourFormat: clock.use24Hour), - child: WindowsDesktopRuntime( - startMinimizedAtLaunch: widget.startMinimizedAtLaunch, - child: child ?? const SizedBox.shrink(), + child: BusyMaxWeekPreferencesScope( + preference: settings.firstDayOfWeekPreference, + systemWeekday: _firstWeekdayController.value, + platformLocaleTag: WidgetsBinding.instance.platformDispatcher.locale + .toLanguageTag(), + child: MediaQuery( + data: MediaQuery.of( + context, + ).copyWith(alwaysUse24HourFormat: clock.use24Hour), + child: WindowsDesktopRuntime( + startMinimizedAtLaunch: widget.startMinimizedAtLaunch, + child: child ?? const SizedBox.shrink(), + ), ), ), ); diff --git a/lib/src/features/recurrence/presentation/recurrence_editor.dart b/lib/src/features/recurrence/presentation/recurrence_editor.dart index db6fb8bb..263f4056 100644 --- a/lib/src/features/recurrence/presentation/recurrence_editor.dart +++ b/lib/src/features/recurrence/presentation/recurrence_editor.dart @@ -470,7 +470,6 @@ class _RecurrenceEditorDialogState extends State { date: _value.untilDateFor(timeZone: widget.timeZone) ?? _dateString(_oneMonthAfter(widget.baseDate)), - useNativePicker: widget.useNativeDatePicker, onChanged: _setUntilDate, ), if (_end == _RecurrenceEnd.count) diff --git a/lib/src/features/schedule/presentation/schedule_search_filters.dart b/lib/src/features/schedule/presentation/schedule_search_filters.dart index 61e21680..d287317f 100644 --- a/lib/src/features/schedule/presentation/schedule_search_filters.dart +++ b/lib/src/features/schedule/presentation/schedule_search_filters.dart @@ -235,7 +235,6 @@ class ScheduleSearchFilters extends StatelessWidget { return DesktopDateValueRow( label: label, date: encodeDateOnly(date), - useNativePicker: true, onChanged: (date) { final picked = DateTime.parse(date); final from = start ? picked : value.customStart ?? value.referenceDate; diff --git a/lib/src/features/schedule/presentation/schedule_workspace.dart b/lib/src/features/schedule/presentation/schedule_workspace.dart index abe2cd5b..7e70dfa7 100644 --- a/lib/src/features/schedule/presentation/schedule_workspace.dart +++ b/lib/src/features/schedule/presentation/schedule_workspace.dart @@ -25,6 +25,7 @@ import '../../../features/feedback/presentation/feedback_dialog.dart'; import '../../../features/sync/sync_auth_error.dart'; import '../../../l10n/l10n.dart'; import '../../../l10n/localized_formatters.dart'; +import '../../../l10n/week_preferences_scope.dart'; import '../../../platform/linux_header_bar_service.dart'; import '../../../platform/linux_header_bar_provider.dart'; import '../../../schedule/schedule_commands.dart'; @@ -189,6 +190,7 @@ class _ScheduleWorkspaceState extends ConsumerState { bool? _sidebarBeforeSearch; ScheduleSearchCriteria? _searchCriteria; ScheduleSearchCriteria? _initialSearchCriteria; + var _searchFirstWeekday = DateTime.monday; List _searchSources = const []; List _searchTaskLists = const []; ScheduleSourceVisibility? _searchVisibility; @@ -260,6 +262,18 @@ class _ScheduleWorkspaceState extends ConsumerState { super.dispose(); } + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final firstWeekday = _firstWeekday(context); + if (_searchFirstWeekday == firstWeekday) return; + _searchFirstWeekday = firstWeekday; + _searchCriteria = _searchCriteria?.copyWith(firstWeekday: firstWeekday); + _initialSearchCriteria = _initialSearchCriteria?.copyWith( + firstWeekday: firstWeekday, + ); + } + @override void didUpdateWidget(covariant ScheduleWorkspace oldWidget) { super.didUpdateWidget(oldWidget); @@ -3218,8 +3232,7 @@ class _ScheduleShortcutAction extends ContextAction<_ScheduleShortcutIntent> { } int _firstWeekday(BuildContext context) { - final index = MaterialLocalizations.of(context).firstDayOfWeekIndex; - return index == 0 ? DateTime.sunday : index; + return BusyMaxWeekPreferencesScope.firstWeekdayOf(context); } DateTime _day(DateTime date) => DateTime(date.year, date.month, date.day); diff --git a/lib/src/features/settings/presentation/settings_screen.dart b/lib/src/features/settings/presentation/settings_screen.dart index 30a2bb67..f4ceecd1 100644 --- a/lib/src/features/settings/presentation/settings_screen.dart +++ b/lib/src/features/settings/presentation/settings_screen.dart @@ -1,4 +1,5 @@ import 'package:busymax/src/l10n/time_format_scope.dart'; +import 'package:busymax/src/l10n/week_preferences_scope.dart'; import 'dart:async'; import 'dart:io'; @@ -197,6 +198,15 @@ class _SettingsScreenState extends ConsumerState { }, onSelected: settingsController.setTimeFormatPreference, ), + BusyMaxComboRow( + title: l10n.firstDayOfWeek, + leading: const Icon(YaruIcons.calendar), + values: BusyMaxFirstDayOfWeekPreference.values, + selected: settings.firstDayOfWeekPreference, + labelFor: (value) => + busyMaxFirstDayOfWeekPreferenceLabel(context, value), + onSelected: settingsController.setFirstDayOfWeekPreference, + ), BusyMaxComboRow( title: l10n.scheduleDayStartsAt, leading: const Icon(YaruIcons.calendar_day), diff --git a/lib/src/features/tasks/presentation/desktop_date_time_fields.dart b/lib/src/features/tasks/presentation/desktop_date_time_fields.dart index 01cf5597..7d606585 100644 --- a/lib/src/features/tasks/presentation/desktop_date_time_fields.dart +++ b/lib/src/features/tasks/presentation/desktop_date_time_fields.dart @@ -9,6 +9,7 @@ import 'package:busymax/src/core/time/time_zone_catalog.dart'; import 'package:busymax/src/l10n/l10n.dart'; import 'package:busymax/src/l10n/localized_formatters.dart'; import 'package:busymax/src/l10n/time_format_scope.dart'; +import 'package:busymax/src/l10n/week_preferences_scope.dart'; import 'package:busymax/src/features/schedule/presentation/mini_calendar.dart'; import 'package:busymax/src/features/schedule/presentation/schedule_anchored_popover.dart'; import 'package:busymax/src/features/tasks/presentation/time_zone_selection_dialog.dart'; @@ -40,21 +41,7 @@ class NativeDateTimePicker { static const _channel = MethodChannel(nativeDateTimePickerChannelName); - Future pickDate({ - required String title, - required String? initialDate, - required String cancelLabel, - required String okLabel, - }) async { - return _invoke('pickDate', { - 'title': title, - 'initialDate': initialDate, - 'cancelLabel': cancelLabel, - 'okLabel': okLabel, - }); - } - - Future pickTime({ + Future pickTime({ required String title, required String? initialTime, required String cancelLabel, @@ -80,24 +67,23 @@ class NativeDateTimePicker { }); } - Future _invoke( + Future _invoke( String method, Map arguments, ) async { try { final value = await _channel.invokeMethod(method, arguments); - return NativeDatePickResult(available: true, date: value, time: value); + return NativeTimePickResult(available: true, time: value); } on MissingPluginException { - return const NativeDatePickResult(available: false); + return const NativeTimePickResult(available: false); } } } -class NativeDatePickResult { - const NativeDatePickResult({required this.available, this.date, this.time}); +class NativeTimePickResult { + const NativeTimePickResult({required this.available, this.time}); final bool available; - final String? date; final String? time; } @@ -109,7 +95,6 @@ class DesktopDateField extends StatefulWidget { required this.onChanged, this.enabled = true, this.onClear, - this.useNativePicker = false, }); final String label; @@ -117,7 +102,6 @@ class DesktopDateField extends StatefulWidget { final ValueChanged onChanged; final bool enabled; final VoidCallback? onClear; - final bool useNativePicker; @override State createState() => _DesktopDateFieldState(); @@ -131,7 +115,6 @@ class DesktopDateValueRow extends StatelessWidget { required this.onChanged, this.enabled = true, this.onClear, - this.useNativePicker = false, }); final String label; @@ -139,7 +122,6 @@ class DesktopDateValueRow extends StatelessWidget { final ValueChanged onChanged; final bool enabled; final VoidCallback? onClear; - final bool useNativePicker; @override Widget build(BuildContext context) { @@ -149,7 +131,6 @@ class DesktopDateValueRow extends StatelessWidget { onChanged: onChanged, enabled: enabled, onClear: onClear, - useNativePicker: useNativePicker, ); } } @@ -199,9 +180,7 @@ class _DesktopDateFieldState extends State { context, labelText: widget.label, ), - onTap: widget.enabled - ? () => _pickNativeDate(context, fieldContext) - : null, + onTap: widget.enabled ? () => _pickDate(context, fieldContext) : null, ), ), trailingIcons: [ @@ -215,7 +194,7 @@ class _DesktopDateFieldState extends State { builder: (buttonContext) => YaruIconButton( tooltip: widget.label, onPressed: widget.enabled - ? () => _pickNativeDate(context, buttonContext) + ? () => _pickDate(context, buttonContext) : null, icon: const Icon(YaruIcons.calendar), ), @@ -236,50 +215,21 @@ class _DesktopDateFieldState extends State { ); } - Future _pickNativeDate( + Future _pickDate( BuildContext context, BuildContext anchorContext, ) async { if (!widget.enabled) { return; } - if (!widget.useNativePicker) { - final fallbackPicked = await showBusyMaxDateValueDialog( - context, - label: widget.label, - initialDate: widget.date, - anchorContext: anchorContext, - ); - if (mounted && fallbackPicked != null) { - _applyPickedDate(fallbackPicked); - } - return; - } - final localizations = MaterialLocalizations.of(context); - final picked = await _nativeDateTimePicker.pickDate( - title: widget.label, - initialDate: widget.date, - cancelLabel: localizations.cancelButtonLabel, - okLabel: localizations.okButtonLabel, - ); - if (!context.mounted) { - return; - } - if (picked.date != null) { - _applyPickedDate(picked.date!); - return; - } - if (picked.available) { - return; - } - final fallbackPicked = await showBusyMaxDateValueDialog( + final picked = await showBusyMaxDateValueDialog( context, label: widget.label, initialDate: widget.date, anchorContext: anchorContext, ); - if (mounted && fallbackPicked != null) { - _applyPickedDate(fallbackPicked); + if (mounted && picked != null) { + _applyPickedDate(picked); } } @@ -393,8 +343,9 @@ class _DesktopDateValueDialog extends StatefulWidget { } class _DesktopDateValueDialogState extends State<_DesktopDateValueDialog> { - static final _firstDate = DateTime(1900); - static final _lastDate = DateTime(2100, 12, 31); + // Match the date-only domain accepted by the prior GTK path. + static final _firstDate = DateTime(1); + static final _lastDate = DateTime(9999, 12, 31); late DateTime _selected; late DateTime _displayedMonth; @@ -634,8 +585,7 @@ class _DesktopDateValueDialogState extends State<_DesktopDateValueDialog> { } int _firstWeekday(BuildContext context) { - final index = MaterialLocalizations.of(context).firstDayOfWeekIndex; - return index == 0 ? DateTime.sunday : index; + return BusyMaxWeekPreferencesScope.firstWeekdayOf(context); } DateTime _supportedInitialDate(String? encodedDate) { diff --git a/lib/src/features/tasks/presentation/ical_task_fields_editor.dart b/lib/src/features/tasks/presentation/ical_task_fields_editor.dart index 96a898d4..d47a802f 100644 --- a/lib/src/features/tasks/presentation/ical_task_fields_editor.dart +++ b/lib/src/features/tasks/presentation/ical_task_fields_editor.dart @@ -220,7 +220,6 @@ class _IcalTaskFieldsEditorState extends ConsumerState { enabled: widget.enabled, onChanged: _setCompletionDate, onClear: () => _setCompletionDate(null), - useNativePicker: widget.useNativeDatePicker, ), if (draft.completedDate != null) DesktopTimeValueRow( @@ -929,7 +928,6 @@ class _TaskReminderDialogState extends State<_TaskReminderDialog> { DesktopDateValueRow( label: l10n.reminderDate, date: _dateString(_absoluteLocal), - useNativePicker: widget.useNativeDatePicker, onChanged: _setAbsoluteDate, ), DesktopTimeValueRow( diff --git a/lib/src/features/tasks/presentation/task_details_editor.dart b/lib/src/features/tasks/presentation/task_details_editor.dart index 12dc5365..35a23906 100644 --- a/lib/src/features/tasks/presentation/task_details_editor.dart +++ b/lib/src/features/tasks/presentation/task_details_editor.dart @@ -270,7 +270,6 @@ class _TaskDetailsEditorState extends State { enabled: _canWrite, onChanged: (value) => _updateDraft(draft.copyWith(dueDate: value)), - useNativePicker: widget.useNativeDatePicker, onClear: () => unawaited( _clearScheduledDate(draft, due: true), ), @@ -575,7 +574,6 @@ class _TaskDetailsEditorState extends State { enabled: _canWrite, onChanged: (value) => _updateDraft(draft.copyWith(microsoftStartDate: value)), - useNativePicker: widget.useNativeDatePicker, onClear: () => unawaited(_clearScheduledDate(draft, due: false)), ), if (!scheduledAllDay) @@ -1267,7 +1265,6 @@ class _TaskDetailsEditorState extends State { enabled: _canWrite, onChanged: (value) => _updateDraft(draft.copyWith(microsoftReminderDate: value)), - useNativePicker: widget.useNativeDatePicker, onClear: () => _updateDraft(draft.copyWith(microsoftReminderDate: null)), ), diff --git a/lib/src/l10n/first_day_of_week.dart b/lib/src/l10n/first_day_of_week.dart new file mode 100644 index 00000000..a274e8ff --- /dev/null +++ b/lib/src/l10n/first_day_of_week.dart @@ -0,0 +1,95 @@ +import 'package:intl/date_symbol_data_local.dart'; +import 'package:intl/intl.dart'; + +final _busyMaxDateSymbols = dateTimeSymbolMap(); + +/// The device-local BusyMax calendar-column preference. +/// +/// Enum names are persisted, so they must remain stable. +enum BusyMaxFirstDayOfWeekPreference { + system, + monday, + tuesday, + wednesday, + thursday, + friday, + saturday, + sunday; + + int? get weekday => switch (this) { + system => null, + monday => DateTime.monday, + tuesday => DateTime.tuesday, + wednesday => DateTime.wednesday, + thursday => DateTime.thursday, + friday => DateTime.friday, + saturday => DateTime.saturday, + sunday => DateTime.sunday, + }; +} + +bool isValidBusyMaxWeekday(int? value) => + value != null && value >= DateTime.monday && value <= DateTime.sunday; + +/// Resolves a saved preference without involving BusyMax's translation locale. +/// +/// [platformLocaleTag] must describe the platform formatting locale. A native +/// result wins over locale data because it can include an OS-level override. +int resolveBusyMaxFirstWeekday({ + required BusyMaxFirstDayOfWeekPreference preference, + required int? systemWeekday, + required String? platformLocaleTag, +}) { + final explicit = preference.weekday; + if (explicit != null) return explicit; + if (isValidBusyMaxWeekday(systemWeekday)) return systemWeekday!; + return busyMaxFirstWeekdayFromLocale(platformLocaleTag) ?? DateTime.monday; +} + +/// Reads a Unicode `fw` override and then CLDR data supplied by `intl`. +int? busyMaxFirstWeekdayFromLocale(String? localeTag) { + final trimmed = localeTag?.trim(); + if (trimmed == null || trimmed.isEmpty) return null; + + final override = _unicodeFirstWeekday(trimmed); + if (override != null) return override; + + final withoutExtensions = trimmed.split(RegExp('[-_]u[-_]')).first; + final canonical = Intl.canonicalizedLocale(withoutExtensions); + final symbols = _busyMaxDateSymbols; + final parts = canonical.split('_'); + final candidates = [ + canonical, + if (parts.length >= 3) '${parts[0]}_${parts.last}', + if (parts.length >= 2) parts.first, + ]; + for (final candidate in candidates) { + final firstFromMonday = symbols[candidate]?.FIRSTDAYOFWEEK; + if (firstFromMonday != null && + firstFromMonday >= 0 && + firstFromMonday < DateTime.daysPerWeek) { + return firstFromMonday + DateTime.monday; + } + } + return null; +} + +int? _unicodeFirstWeekday(String localeTag) { + final parts = localeTag.toLowerCase().split(RegExp('[-_]')); + final unicode = parts.indexOf('u'); + if (unicode < 0) return null; + for (var index = unicode + 1; index + 1 < parts.length; index++) { + if (parts[index] != 'fw') continue; + return switch (parts[index + 1]) { + 'mon' => DateTime.monday, + 'tue' => DateTime.tuesday, + 'wed' => DateTime.wednesday, + 'thu' => DateTime.thursday, + 'fri' => DateTime.friday, + 'sat' => DateTime.saturday, + 'sun' => DateTime.sunday, + _ => null, + }; + } + return null; +} diff --git a/lib/src/l10n/week_preferences_scope.dart b/lib/src/l10n/week_preferences_scope.dart new file mode 100644 index 00000000..0ca7a94a --- /dev/null +++ b/lib/src/l10n/week_preferences_scope.dart @@ -0,0 +1,137 @@ +import 'dart:async'; + +import 'package:flutter/widgets.dart'; + +import 'first_day_of_week.dart'; +import 'l10n.dart'; +import 'localized_formatters.dart'; + +export 'first_day_of_week.dart'; + +/// Injectable boundary for platform-specific regional settings. +abstract interface class BusyMaxSystemFirstWeekdaySource { + Future read(); + + Stream get changes; + + void dispose(); +} + +abstract base class BusyMaxSystemFirstWeekdaySourceBase + implements BusyMaxSystemFirstWeekdaySource { + const BusyMaxSystemFirstWeekdaySourceBase(); + + @override + Stream get changes => const Stream.empty(); + + @override + void dispose() {} +} + +/// Application-lifetime owner of native refreshes and lifecycle observation. +class BusyMaxSystemFirstWeekdayController extends ValueNotifier + with WidgetsBindingObserver { + BusyMaxSystemFirstWeekdayController(this._source) : super(null) { + WidgetsBinding.instance.addObserver(this); + _subscription = _source.changes.listen( + (_) => unawaited(refresh()), + onError: (Object _) {}, + ); + unawaited(refresh()); + } + + final BusyMaxSystemFirstWeekdaySource _source; + StreamSubscription? _subscription; + var _refreshSequence = 0; + var _disposed = false; + + Future refresh() async { + final sequence = ++_refreshSequence; + int? result; + try { + result = await _source.read(); + } on Object { + result = null; + } + if (_disposed || sequence != _refreshSequence) return; + value = isValidBusyMaxWeekday(result) ? result : null; + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (state == AppLifecycleState.resumed) unawaited(refresh()); + } + + @override + void didChangeLocales(List? locales) => unawaited(refresh()); + + @override + void dispose() { + _disposed = true; + _refreshSequence++; + WidgetsBinding.instance.removeObserver(this); + unawaited(_subscription?.cancel()); + _source.dispose(); + super.dispose(); + } +} + +/// Place above the navigator so routes and overlays share one effective value. +class BusyMaxWeekPreferencesScope extends InheritedWidget { + const BusyMaxWeekPreferencesScope({ + required this.preference, + required this.systemWeekday, + required this.platformLocaleTag, + required super.child, + super.key, + }); + + final BusyMaxFirstDayOfWeekPreference preference; + final int? systemWeekday; + final String? platformLocaleTag; + + int get firstWeekday => resolveBusyMaxFirstWeekday( + preference: preference, + systemWeekday: systemWeekday, + platformLocaleTag: platformLocaleTag, + ); + + static BusyMaxWeekPreferencesScope? maybeOf(BuildContext context) => + context.dependOnInheritedWidgetOfExactType(); + + static BusyMaxWeekPreferencesScope of(BuildContext context) { + final scope = maybeOf(context); + return scope ?? + BusyMaxWeekPreferencesScope( + preference: BusyMaxFirstDayOfWeekPreference.system, + systemWeekday: null, + platformLocaleTag: WidgetsBinding.instance.platformDispatcher.locale + .toLanguageTag(), + child: const SizedBox.shrink(), + ); + } + + static int firstWeekdayOf(BuildContext context) => of(context).firstWeekday; + + @override + bool updateShouldNotify(BusyMaxWeekPreferencesScope oldWidget) => + oldWidget.preference != preference || + oldWidget.systemWeekday != systemWeekday || + oldWidget.platformLocaleTag != platformLocaleTag; +} + +String busyMaxFirstDayOfWeekPreferenceLabel( + BuildContext context, + BusyMaxFirstDayOfWeekPreference preference, +) { + final effective = + preference.weekday ?? BusyMaxWeekPreferencesScope.firstWeekdayOf(context); + final locale = Localizations.localeOf(context).toLanguageTag(); + final weekday = localizedWeekdayLabel( + locale, + DateTime(2026, DateTime.january, 5 + effective - DateTime.monday), + ); + return preference == BusyMaxFirstDayOfWeekPreference.system + ? context.l10n.systemDefaultResolved(weekday) + : weekday; +} diff --git a/lib/src/platform/android/android_first_weekday_source.dart b/lib/src/platform/android/android_first_weekday_source.dart new file mode 100644 index 00000000..6b555485 --- /dev/null +++ b/lib/src/platform/android/android_first_weekday_source.dart @@ -0,0 +1,19 @@ +import 'package:busymax_android_platform/busymax_android_platform.dart'; + +import '../../l10n/week_preferences_scope.dart'; + +final class AndroidFirstWeekdaySource + extends BusyMaxSystemFirstWeekdaySourceBase { + AndroidFirstWeekdaySource({BusyMaxAndroidPlatform? platform}) + : _platform = platform ?? BusyMaxAndroidPlatform.instance; + + final BusyMaxAndroidPlatform _platform; + + @override + Future read() => _platform.getFirstWeekday(); + + @override + Stream get changes => _platform.events + .where((event) => event.type == 'systemSettingsChanged') + .map((_) {}); +} diff --git a/lib/src/platform/linux_first_weekday_source.dart b/lib/src/platform/linux_first_weekday_source.dart new file mode 100644 index 00000000..524b04ea --- /dev/null +++ b/lib/src/platform/linux_first_weekday_source.dart @@ -0,0 +1,28 @@ +import 'package:flutter/services.dart'; + +import '../l10n/week_preferences_scope.dart'; + +/// GNOME regional-format changes can require a new desktop session before +/// glibc exposes a different effective LC_TIME locale. Automatic mode follows +/// that effective session value; an explicit BusyMax preference is immediate. +final class LinuxFirstWeekdaySource + extends BusyMaxSystemFirstWeekdaySourceBase { + const LinuxFirstWeekdaySource({ + MethodChannel channel = const MethodChannel( + 'io.busystack.busymax/gtk_settings', + ), + }) : _channel = channel; + + final MethodChannel _channel; + + @override + Future read() async { + try { + return await _channel.invokeMethod('getFirstWeekday'); + } on MissingPluginException { + return null; + } on PlatformException { + return null; + } + } +} diff --git a/lib/src/platform/windows/windows_first_weekday_source.dart b/lib/src/platform/windows/windows_first_weekday_source.dart new file mode 100644 index 00000000..237980aa --- /dev/null +++ b/lib/src/platform/windows/windows_first_weekday_source.dart @@ -0,0 +1,44 @@ +import 'dart:async'; + +import 'package:flutter/services.dart'; + +import '../../l10n/week_preferences_scope.dart'; + +final class WindowsFirstWeekdaySource + implements BusyMaxSystemFirstWeekdaySource { + WindowsFirstWeekdaySource({ + MethodChannel channel = const MethodChannel('busymax/windows_weekday'), + }) : _channel = channel { + _channel.setMethodCallHandler((call) async { + if (!_disposed && call.method == 'weekdayChanged') { + _changes.add(null); + } + }); + } + + final MethodChannel _channel; + final _changes = StreamController.broadcast(sync: true); + var _disposed = false; + + @override + Stream get changes => _changes.stream; + + @override + Future read() async { + try { + return await _channel.invokeMethod('getFirstWeekday'); + } on MissingPluginException { + return null; + } on PlatformException { + return null; + } + } + + @override + void dispose() { + if (_disposed) return; + _disposed = true; + _channel.setMethodCallHandler(null); + unawaited(_changes.close()); + } +} diff --git a/lib/src/schedule/schedule_range.dart b/lib/src/schedule/schedule_range.dart index 91e5e68c..a3e99c57 100644 --- a/lib/src/schedule/schedule_range.dart +++ b/lib/src/schedule/schedule_range.dart @@ -6,10 +6,7 @@ class ScheduleRange { return ScheduleRange(start: start, end: _addCalendarDays(start, 1)); } - factory ScheduleRange.week( - DateTime day, { - int firstWeekday = DateTime.monday, - }) { + factory ScheduleRange.week(DateTime day, {required int firstWeekday}) { final date = _addCalendarDays(day, 0); final offset = (date.weekday - firstWeekday) % DateTime.daysPerWeek; final start = _addCalendarDays(date, -offset); @@ -19,10 +16,7 @@ class ScheduleRange { ); } - factory ScheduleRange.month( - DateTime day, { - int firstWeekday = DateTime.monday, - }) { + factory ScheduleRange.month(DateTime day, {required int firstWeekday}) { final first = DateTime(day.year, day.month); final offset = (first.weekday - firstWeekday) % DateTime.daysPerWeek; final start = _addCalendarDays(first, -offset); diff --git a/lib/src/schedule/schedule_search_criteria.dart b/lib/src/schedule/schedule_search_criteria.dart index f12cb07f..9e544ba5 100644 --- a/lib/src/schedule/schedule_search_criteria.dart +++ b/lib/src/schedule/schedule_search_criteria.dart @@ -90,6 +90,7 @@ class ScheduleSearchCriteria { String? location, DateTime? customStart, DateTime? customEnd, + int? firstWeekday, Set? sourceIds, Set? taskListKeys, }) => ScheduleSearchCriteria( @@ -97,14 +98,12 @@ class ScheduleSearchCriteria { date: date ?? this.date, taskCompletion: taskCompletion ?? this.taskCompletion, taskDueState: taskDueState ?? this.taskDueState, - person: (type ?? this.type) == ScheduleSearchType.tasks - ? '' - : person ?? this.person, + person: type == ScheduleSearchType.tasks ? '' : person ?? this.person, location: location ?? this.location, customStart: customStart ?? this.customStart, customEnd: customEnd ?? this.customEnd, referenceDate: referenceDate, - firstWeekday: firstWeekday, + firstWeekday: firstWeekday ?? this.firstWeekday, sourceIds: sourceIds ?? this.sourceIds, taskListKeys: taskListKeys ?? this.taskListKeys, ); diff --git a/lib/src/ui/windows/windows_schedule_page.dart b/lib/src/ui/windows/windows_schedule_page.dart index d5fc207a..220d5f2e 100644 --- a/lib/src/ui/windows/windows_schedule_page.dart +++ b/lib/src/ui/windows/windows_schedule_page.dart @@ -1,4 +1,5 @@ import 'package:busymax/src/l10n/time_format_scope.dart'; +import 'package:busymax/src/l10n/week_preferences_scope.dart'; import 'dart:async'; import '../../providers/busy_provider.dart'; import 'dart:math' as math; @@ -64,6 +65,7 @@ class _WindowsSchedulePageState extends ConsumerState { bool? _sourcePaneBeforeSearch; ScheduleSearchCriteria? _searchCriteria; ScheduleSearchCriteria? _initialSearchCriteria; + var _firstWeekday = DateTime.monday; ScheduleSourceVisibility? _latestVisibility; late ScheduleViewMode _mode; var _agendaDays = 30; @@ -137,6 +139,19 @@ class _WindowsSchedulePageState extends ConsumerState { super.dispose(); } + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final firstWeekday = BusyMaxWeekPreferencesScope.firstWeekdayOf(context); + if (_firstWeekday == firstWeekday) return; + _firstWeekday = firstWeekday; + _searchCriteria = _searchCriteria?.copyWith(firstWeekday: firstWeekday); + _initialSearchCriteria = _initialSearchCriteria?.copyWith( + firstWeekday: firstWeekday, + ); + _itemsKey = null; + } + Future _revealPendingCommand() async { final command = _pendingCommand; if (command == null) return; @@ -292,6 +307,7 @@ class _WindowsSchedulePageState extends ConsumerState { final sourcePane = _searchActive && _searchCriteria != null ? _searchPane(accounts, sources, taskLists) : WindowsScheduleSourcePane( + firstWeekday: _firstWeekday, selectedDate: _selectedDate, accounts: accounts, calendarSources: sources, @@ -572,6 +588,7 @@ class _WindowsSchedulePageState extends ConsumerState { ); } return _ScheduleModeView( + firstWeekday: _firstWeekday, mode: _mode, selectedDate: _selectedDate, range: range, @@ -718,7 +735,7 @@ class _WindowsSchedulePageState extends ConsumerState { final now = DateTime.now(); _initialSearchCriteria = ScheduleSearchCriteria( referenceDate: DateTime(now.year, now.month, now.day), - firstWeekday: DateTime.monday, + firstWeekday: _firstWeekday, sourceIds: visibility.visibleCalendarSourceIds, taskListKeys: visibility.visibleTaskListKeys, ); @@ -899,8 +916,14 @@ class _WindowsSchedulePageState extends ConsumerState { ScheduleRange _rangeForMode() => switch (_mode) { ScheduleViewMode.day => ScheduleRange.day(_selectedDate), - ScheduleViewMode.week => ScheduleRange.week(_selectedDate), - ScheduleViewMode.month => ScheduleRange.month(_selectedDate), + ScheduleViewMode.week => ScheduleRange.week( + _selectedDate, + firstWeekday: _firstWeekday, + ), + ScheduleViewMode.month => ScheduleRange.month( + _selectedDate, + firstWeekday: _firstWeekday, + ), ScheduleViewMode.year => ScheduleRange.year(_selectedDate), ScheduleViewMode.agenda => ScheduleRange( start: _dateOnly(_selectedDate), @@ -1051,6 +1074,7 @@ class _WindowsSchedulePageState extends ConsumerState { width: 520, height: math.min(MediaQuery.sizeOf(context).height - 180, 620), child: WindowsScheduleSourcePane( + firstWeekday: _firstWeekday, selectedDate: _selectedDate, accounts: accounts, calendarSources: sources, @@ -1586,6 +1610,7 @@ class _ViewModeMenu extends StatelessWidget { class _ScheduleModeView extends StatelessWidget { const _ScheduleModeView({ required this.mode, + required this.firstWeekday, required this.selectedDate, required this.range, required this.items, @@ -1603,6 +1628,7 @@ class _ScheduleModeView extends StatelessWidget { }); final ScheduleViewMode mode; + final int firstWeekday; final DateTime selectedDate; final ScheduleRange range; final List items; @@ -1636,6 +1662,7 @@ class _ScheduleModeView extends StatelessWidget { dayEndMinute: dayEndMinute, ), ScheduleViewMode.month => WindowsScheduleMonthView( + firstWeekday: firstWeekday, onReschedule: onReschedule, selectedDate: selectedDate, range: range, @@ -1645,6 +1672,7 @@ class _ScheduleModeView extends StatelessWidget { onSelectDate: onSelectDate, ), ScheduleViewMode.year => WindowsScheduleYearView( + firstWeekday: firstWeekday, selectedDate: selectedDate, items: items, locale: locale, @@ -1759,6 +1787,7 @@ class WindowsScheduleMonthView extends StatelessWidget { const WindowsScheduleMonthView({ super.key, required this.selectedDate, + required this.firstWeekday, required this.onReschedule, required this.range, required this.items, @@ -1768,6 +1797,7 @@ class WindowsScheduleMonthView extends StatelessWidget { }); final DateTime selectedDate; + final int firstWeekday; final ScheduleRescheduleCallback onReschedule; final ScheduleRange range; final List items; @@ -1802,9 +1832,9 @@ class WindowsScheduleMonthView extends StatelessWidget { Expanded( child: Center( child: Text( - DateFormat.E( - locale, - ).format(DateTime(2026, 1, 5 + index)), + DateFormat.E(locale).format( + DateTime(2026, 1, 5 + firstWeekday - 1 + index), + ), style: FluentTheme.of(context).typography.caption, ), ), @@ -1958,12 +1988,14 @@ class WindowsScheduleYearView extends StatelessWidget { const WindowsScheduleYearView({ super.key, required this.selectedDate, + required this.firstWeekday, required this.items, required this.locale, required this.onSelectDate, }); final DateTime selectedDate; + final int firstWeekday; final List items; final String locale; final ValueChanged onSelectDate; @@ -1986,7 +2018,7 @@ class WindowsScheduleYearView extends StatelessWidget { itemCount: 12, itemBuilder: (context, index) { final month = DateTime(selectedDate.year, index + 1); - final offset = month.weekday - 1; + final offset = (month.weekday - firstWeekday) % DateTime.daysPerWeek; final dayCount = DateTime(month.year, month.month + 1, 0).day; return Card( padding: const EdgeInsets.all(8), @@ -2003,9 +2035,9 @@ class WindowsScheduleYearView extends StatelessWidget { Expanded( child: Center( child: Text( - DateFormat.E( - locale, - ).format(DateTime(2026, 1, 5 + weekday)), + DateFormat.E(locale).format( + DateTime(2026, 1, 5 + firstWeekday - 1 + weekday), + ), maxLines: 1, overflow: TextOverflow.ellipsis, style: FluentTheme.of(context).typography.caption, diff --git a/lib/src/ui/windows/windows_schedule_source_pane.dart b/lib/src/ui/windows/windows_schedule_source_pane.dart index 6211026c..7d1a80e6 100644 --- a/lib/src/ui/windows/windows_schedule_source_pane.dart +++ b/lib/src/ui/windows/windows_schedule_source_pane.dart @@ -25,6 +25,7 @@ import 'windows_busymax_glyphs.dart'; class WindowsScheduleSourcePane extends ConsumerStatefulWidget { const WindowsScheduleSourcePane({ + required this.firstWeekday, required this.selectedDate, required this.accounts, required this.calendarSources, @@ -39,6 +40,7 @@ class WindowsScheduleSourcePane extends ConsumerStatefulWidget { }); final DateTime selectedDate; + final int firstWeekday; final List accounts; final List calendarSources; final List taskLists; @@ -141,6 +143,7 @@ class _WindowsScheduleSourcePaneState Padding( padding: const EdgeInsets.fromLTRB(12, 12, 12, 8), child: _MiniMonth( + firstWeekday: widget.firstWeekday, displayedMonth: _displayedMonth, selectedDate: widget.selectedDate, locale: locale, @@ -862,6 +865,7 @@ bool _isCurrentCalendarColor( class _MiniMonth extends StatelessWidget { const _MiniMonth({ required this.displayedMonth, + required this.firstWeekday, required this.selectedDate, required this.locale, required this.onPrevious, @@ -870,6 +874,7 @@ class _MiniMonth extends StatelessWidget { }); final DateTime displayedMonth; + final int firstWeekday; final DateTime selectedDate; final String locale; final VoidCallback onPrevious; @@ -882,13 +887,13 @@ class _MiniMonth extends StatelessWidget { final gridStart = DateTime( first.year, first.month, - first.day - (first.weekday - DateTime.monday), + first.day - ((first.weekday - firstWeekday) % DateTime.daysPerWeek), ); final days = [ for (var offset = 0; offset < 42; offset += 1) DateTime(gridStart.year, gridStart.month, gridStart.day + offset), ]; - final weekdayBase = DateTime(2026, 1, 5); + final weekdayBase = DateTime(2026, 1, 5 + firstWeekday - 1); return Column( children: [ Row( diff --git a/lib/src/ui/windows/windows_settings_page.dart b/lib/src/ui/windows/windows_settings_page.dart index 9037ea31..7205d606 100644 --- a/lib/src/ui/windows/windows_settings_page.dart +++ b/lib/src/ui/windows/windows_settings_page.dart @@ -17,6 +17,7 @@ import '../../features/notifications/desktop_notification_backend.dart'; import '../../features/sync/sync_auth_error.dart'; import '../../features/settings/presentation/launch_at_login_refresh.dart'; import '../../l10n/app_locale.dart'; +import '../../l10n/week_preferences_scope.dart'; import '../../platform/common/desktop_services.dart'; import '../../providers/busy_provider.dart'; import '../../webcal/webcal_subscription_service.dart'; @@ -146,6 +147,28 @@ class _WindowsSettingsPageState extends ConsumerState { ), ), const Divider(), + _SettingsRow( + icon: BusyMaxGlyph.calendar, + title: l10n.firstDayOfWeek, + child: ComboBox( + value: settings.firstDayOfWeekPreference, + items: [ + for (final value in BusyMaxFirstDayOfWeekPreference.values) + ComboBoxItem( + value: value, + child: Text( + busyMaxFirstDayOfWeekPreferenceLabel(context, value), + ), + ), + ], + onChanged: (value) { + if (value != null) { + controller.setFirstDayOfWeekPreference(value); + } + }, + ), + ), + const Divider(), _SettingsRow( icon: BusyMaxGlyph.calendar, title: l10n.scheduleDayStartsAt, diff --git a/linux/runner/my_application.cc b/linux/runner/my_application.cc index a226ea8a..fe11cc97 100644 --- a/linux/runner/my_application.cc +++ b/linux/runner/my_application.cc @@ -5,10 +5,12 @@ #include #include #include +#include #include #include #include #include +#include #include #include "flutter/generated_plugin_registrant.h" @@ -425,29 +427,6 @@ static FlValue* fl_lookup_map_arg(FlValue* args, const gchar* key) { : nullptr; } -static gboolean parse_date(const gchar* value, - guint* year, - guint* month, - guint* day) { - if (value == nullptr) { - return FALSE; - } - unsigned int parsed_year = 0; - unsigned int parsed_month = 0; - unsigned int parsed_day = 0; - if (sscanf(value, "%u-%u-%u", &parsed_year, &parsed_month, &parsed_day) != 3) { - return FALSE; - } - if (parsed_year < 1 || parsed_month < 1 || parsed_month > 12 || - parsed_day < 1 || parsed_day > 31) { - return FALSE; - } - *year = parsed_year; - *month = parsed_month; - *day = parsed_day; - return TRUE; -} - static gboolean parse_time(const gchar* value, guint* hour, guint* minute) { return busymax_time_picker::ParseCanonical(value, hour, minute); } @@ -468,55 +447,6 @@ static void style_native_dialog(GtkWidget* dialog) { } } -static void handle_pick_date(FlMethodCall* method_call, - FlValue* args, - GtkWindow* parent) { - const gchar* title = fl_lookup_string_arg(args, "title"); - const gchar* initial_date = fl_lookup_string_arg(args, "initialDate"); - const gchar* cancel_label = fl_lookup_string_arg(args, "cancelLabel"); - const gchar* ok_label = fl_lookup_string_arg(args, "okLabel"); - GtkWidget* dialog = gtk_dialog_new_with_buttons( - title != nullptr ? title : "Date", parent, - static_cast(GTK_DIALOG_MODAL | - GTK_DIALOG_DESTROY_WITH_PARENT | - GTK_DIALOG_USE_HEADER_BAR), - cancel_label != nullptr && cancel_label[0] != '\0' ? cancel_label : "Cancel", - GTK_RESPONSE_CANCEL, - ok_label != nullptr && ok_label[0] != '\0' ? ok_label : "OK", - GTK_RESPONSE_OK, - nullptr); - style_native_dialog(dialog); - gtk_dialog_set_default_response(GTK_DIALOG(dialog), GTK_RESPONSE_OK); - gtk_window_set_resizable(GTK_WINDOW(dialog), FALSE); - - GtkWidget* content = gtk_dialog_get_content_area(GTK_DIALOG(dialog)); - GtkWidget* calendar = gtk_calendar_new(); - gtk_container_set_border_width(GTK_CONTAINER(content), 12); - gtk_container_add(GTK_CONTAINER(content), calendar); - - guint year = 0; - guint month = 0; - guint day = 0; - if (parse_date(initial_date, &year, &month, &day)) { - gtk_calendar_select_month(GTK_CALENDAR(calendar), month - 1, year); - gtk_calendar_select_day(GTK_CALENDAR(calendar), day); - } - - gtk_widget_show_all(dialog); - const gint response = gtk_dialog_run(GTK_DIALOG(dialog)); - - if (response == GTK_RESPONSE_OK) { - gtk_calendar_get_date(GTK_CALENDAR(calendar), &year, &month, &day); - g_autofree gchar* result = - g_strdup_printf("%04u-%02u-%02u", year, month + 1, day); - respond_string(method_call, result); - } else { - respond_string(method_call, nullptr); - } - - gtk_widget_destroy(dialog); -} - static void handle_pick_time(FlMethodCall* method_call, FlValue* args, GtkWindow* parent) { @@ -583,9 +513,7 @@ static void native_date_time_picker_method_call_cb(FlMethodChannel* channel, GtkWindow* parent = GTK_WINDOW(user_data); const gchar* method = fl_method_call_get_name(method_call); FlValue* args = fl_method_call_get_args(method_call); - if (strcmp(method, "pickDate") == 0) { - handle_pick_date(method_call, args, parent); - } else if (strcmp(method, "pickTime") == 0) { + if (strcmp(method, "pickTime") == 0) { handle_pick_time(method_call, args, parent); } else { fl_method_call_respond_not_implemented(method_call, nullptr); @@ -4667,6 +4595,36 @@ static void gtk_settings_method_call_cb(FlMethodChannel* channel, if (strcmp(method, "getGtkFont") == 0) { g_autoptr(FlValue) result = get_gtk_font_settings(); fl_method_call_respond_success(method_call, result, nullptr); + } else if (strcmp(method, "getFirstWeekday") == 0) { +#if defined(_NL_TIME_FIRST_WEEKDAY) && defined(_NL_TIME_WEEK_1STDAY) + const char* weekday_bytes = nl_langinfo(_NL_TIME_FIRST_WEEKDAY); + const char* origin_bytes = nl_langinfo(_NL_TIME_WEEK_1STDAY); + if (weekday_bytes == nullptr || origin_bytes == nullptr) { + fl_method_call_respond_success(method_call, nullptr, nullptr); + return; + } + const guint relative_weekday = + static_cast(weekday_bytes[0]); + std::uint32_t origin = 0; + std::memcpy(&origin, origin_bytes, sizeof(origin)); + gint origin_weekday = 0; + if (origin == 19971130) { + origin_weekday = 7; // Sunday in Dart numbering. + } else if (origin == 19971201) { + origin_weekday = 1; // Monday in Dart numbering. + } + if (origin_weekday == 0 || relative_weekday < 1 || + relative_weekday > 7) { + fl_method_call_respond_success(method_call, nullptr, nullptr); + return; + } + const gint dart_weekday = + ((origin_weekday + static_cast(relative_weekday) - 2) % 7) + 1; + g_autoptr(FlValue) result = fl_value_new_int(dart_weekday); + fl_method_call_respond_success(method_call, result, nullptr); +#else + fl_method_call_respond_success(method_call, nullptr, nullptr); +#endif } else if (strcmp(method, "getGtkThemeColors") == 0) { g_autoptr(FlValue) result = get_gtk_theme_colors(); fl_method_call_respond_success(method_call, result, nullptr); diff --git a/packages/busymax_android_platform/android/build.gradle.kts b/packages/busymax_android_platform/android/build.gradle.kts index 43777116..a93f0824 100644 --- a/packages/busymax_android_platform/android/build.gradle.kts +++ b/packages/busymax_android_platform/android/build.gradle.kts @@ -75,6 +75,7 @@ kotlin { } dependencies { + implementation("androidx.core:core:1.19.0") implementation("com.google.android.gms:play-services-auth:21.5.0") implementation("com.microsoft.identity.client:msal:8.4.1") testImplementation("org.junit.jupiter:junit-jupiter:5.11.4") diff --git a/packages/busymax_android_platform/android/src/main/kotlin/io/busystack/busymax_android_platform/BusymaxAndroidPlatformPlugin.kt b/packages/busymax_android_platform/android/src/main/kotlin/io/busystack/busymax_android_platform/BusymaxAndroidPlatformPlugin.kt index 980f15f1..7494ec23 100644 --- a/packages/busymax_android_platform/android/src/main/kotlin/io/busystack/busymax_android_platform/BusymaxAndroidPlatformPlugin.kt +++ b/packages/busymax_android_platform/android/src/main/kotlin/io/busystack/busymax_android_platform/BusymaxAndroidPlatformPlugin.kt @@ -17,6 +17,7 @@ import android.provider.OpenableColumns import android.provider.Settings import android.text.format.DateFormat import androidx.core.content.ContextCompat +import androidx.core.text.util.LocalePreferences import com.google.android.gms.auth.GoogleAuthUtil import com.google.android.gms.auth.api.identity.AuthorizationRequest import com.google.android.gms.auth.api.identity.AuthorizationResult @@ -97,6 +98,17 @@ internal data class MicrosoftSilentFailure( internal class MicrosoftAccountMissingException : IllegalStateException() +internal fun dartWeekday(firstDay: String?): Int? = when (firstDay) { + LocalePreferences.FirstDayOfWeek.MONDAY -> 1 + LocalePreferences.FirstDayOfWeek.TUESDAY -> 2 + LocalePreferences.FirstDayOfWeek.WEDNESDAY -> 3 + LocalePreferences.FirstDayOfWeek.THURSDAY -> 4 + LocalePreferences.FirstDayOfWeek.FRIDAY -> 5 + LocalePreferences.FirstDayOfWeek.SATURDAY -> 6 + LocalePreferences.FirstDayOfWeek.SUNDAY -> 7 + else -> null +} + /** Maps MSAL failures without turning transport or configuration faults into logout state. */ internal fun classifyMicrosoftSilentFailure(error: Throwable): MicrosoftSilentFailure = when (error) { is MicrosoftAccountMissingException, @@ -237,6 +249,7 @@ class BusymaxAndroidPlatformPlugin : FlutterPlugin, when (call.method) { "currentTimeZoneId" -> result.success(TimeZone.getDefault().id) "uses24HourFormat" -> result.success(DateFormat.is24HourFormat(context)) + "getFirstWeekday" -> result.success(firstWeekday()) "googleAuthorizationAvailable" -> result.success(GoogleApiAvailability.getInstance().isGooglePlayServicesAvailable(context) == 0) "microsoftAuthorizationAvailable" -> result.success( context.resources.getIdentifier("busymax_msal_config", "raw", context.packageName) != 0, @@ -285,6 +298,9 @@ class BusymaxAndroidPlatformPlugin : FlutterPlugin, } } + private fun firstWeekday(): Int? = + dartWeekday(LocalePreferences.getFirstDayOfWeek(true)) + private fun authorizeGoogle(call: MethodCall, result: MethodChannel.Result, interactive: Boolean) { val scopes = stringList(call.argument>("scopes")) if (scopes.isEmpty()) return result.error("android/invalid-arguments", "Scopes are required.", null) diff --git a/packages/busymax_android_platform/android/src/test/kotlin/io/busystack/busymax_android_platform/BusymaxAndroidPlatformPluginTest.kt b/packages/busymax_android_platform/android/src/test/kotlin/io/busystack/busymax_android_platform/BusymaxAndroidPlatformPluginTest.kt index 1f3005b3..b5dd59fc 100644 --- a/packages/busymax_android_platform/android/src/test/kotlin/io/busystack/busymax_android_platform/BusymaxAndroidPlatformPluginTest.kt +++ b/packages/busymax_android_platform/android/src/test/kotlin/io/busystack/busymax_android_platform/BusymaxAndroidPlatformPluginTest.kt @@ -2,6 +2,7 @@ package io.busystack.busymax_android_platform import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodChannel +import androidx.core.text.util.LocalePreferences import com.microsoft.identity.client.exception.MsalClientException import com.microsoft.identity.client.exception.MsalServiceException import com.microsoft.identity.client.exception.MsalUiRequiredException @@ -11,6 +12,24 @@ import org.junit.jupiter.api.Test import org.mockito.Mockito internal class BusymaxAndroidPlatformPluginTest { + @Test + fun firstWeekday_convertsEveryAndroidxValueToDartNumbering() { + val values = listOf( + LocalePreferences.FirstDayOfWeek.MONDAY to 1, + LocalePreferences.FirstDayOfWeek.TUESDAY to 2, + LocalePreferences.FirstDayOfWeek.WEDNESDAY to 3, + LocalePreferences.FirstDayOfWeek.THURSDAY to 4, + LocalePreferences.FirstDayOfWeek.FRIDAY to 5, + LocalePreferences.FirstDayOfWeek.SATURDAY to 6, + LocalePreferences.FirstDayOfWeek.SUNDAY to 7, + ) + values.forEach { (value, expected) -> + assertEquals(expected, dartWeekday(value)) + } + assertEquals(null, dartWeekday(null)) + assertEquals(null, dartWeekday("unexpected")) + } + @Test fun unknownMethod_isReportedAsNotImplemented() { val plugin = BusymaxAndroidPlatformPlugin() diff --git a/packages/busymax_android_platform/lib/busymax_android_platform.dart b/packages/busymax_android_platform/lib/busymax_android_platform.dart index b11deac2..5cd7e76f 100644 --- a/packages/busymax_android_platform/lib/busymax_android_platform.dart +++ b/packages/busymax_android_platform/lib/busymax_android_platform.dart @@ -32,6 +32,10 @@ final class BusyMaxAndroidPlatform { Future uses24HourFormat() async => await _methodChannel.invokeMethod('uses24HourFormat') ?? false; + /// Dart weekday numbering (Monday = 1 through Sunday = 7), or unavailable. + Future getFirstWeekday() => + _methodChannel.invokeMethod('getFirstWeekday'); + Future googleAuthorizationAvailable() async => await _methodChannel.invokeMethod('googleAuthorizationAvailable') ?? false; diff --git a/test/app/app_settings_controller_test.dart b/test/app/app_settings_controller_test.dart index baf4de6a..ff0bcf6d 100644 --- a/test/app/app_settings_controller_test.dart +++ b/test/app/app_settings_controller_test.dart @@ -32,6 +32,33 @@ void main() { ); }); + test( + 'weekday changes queued during loading persist and survive restart', + () async { + final store = _DelayedLoadSettingsStore(); + final controller = AppSettingsController(store); + addTearDown(controller.dispose); + final write = controller.setFirstDayOfWeekPreference( + BusyMaxFirstDayOfWeekPreference.saturday, + ); + store.completeLoad({ + 'localeTag': 'de', + 'firstDayOfWeekPreference': 'tuesday', + }); + await write; + expect( + controller.state.firstDayOfWeekPreference, + BusyMaxFirstDayOfWeekPreference.saturday, + ); + expect(store.persisted['firstDayOfWeekPreference'], 'saturday'); + expect( + AppSettings.fromJson(store.persisted).firstDayOfWeekPreference, + BusyMaxFirstDayOfWeekPreference.saturday, + ); + expect(store.persisted['localeTag'], 'de'); + }, + ); + test('clock preference save failure uses the existing retry path', () async { final store = _FailFirstSaveSettingsStore(); final failures = []; diff --git a/test/app/app_settings_test.dart b/test/app/app_settings_test.dart index 364de335..c140de5c 100644 --- a/test/app/app_settings_test.dart +++ b/test/app/app_settings_test.dart @@ -40,6 +40,26 @@ void main() { ); }, ); + test('all first-day preferences persist by stable name', () { + for (final preference in BusyMaxFirstDayOfWeekPreference.values) { + final settings = AppSettings.defaults().copyWith( + firstDayOfWeekPreference: preference, + localeTag: 'en', + ); + final json = settings.toJson(); + expect(json['firstDayOfWeekPreference'], preference.name); + expect(AppSettings.fromJson(json).firstDayOfWeekPreference, preference); + expect(AppSettings.fromJson(json).localeTag, 'en'); + } + for (final invalid in [null, 'Monday', 'unknown', 1]) { + expect( + AppSettings.fromJson({ + 'firstDayOfWeekPreference': invalid, + }).firstDayOfWeekPreference, + BusyMaxFirstDayOfWeekPreference.system, + ); + } + }); test('sidebar order defaults, serialization and copyWith are immutable', () { expect(AppSettings.defaults().sidebarOrder.accountIds, isEmpty); expect(AppSettings.fromJson(const {}).sidebarOrder.accountIds, isEmpty); diff --git a/test/app/first_day_of_week_test.dart b/test/app/first_day_of_week_test.dart new file mode 100644 index 00000000..9e96ea2b --- /dev/null +++ b/test/app/first_day_of_week_test.dart @@ -0,0 +1,177 @@ +import 'dart:async'; + +import 'package:busymax/l10n/generated/app_localizations.dart'; +import 'package:busymax/src/l10n/week_preferences_scope.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + test('all preferences resolve with explicit choices taking precedence', () { + for (final preference in BusyMaxFirstDayOfWeekPreference.values.skip(1)) { + expect( + resolveBusyMaxFirstWeekday( + preference: preference, + systemWeekday: DateTime.sunday, + platformLocaleTag: 'en-US', + ), + preference.weekday, + ); + } + expect( + resolveBusyMaxFirstWeekday( + preference: BusyMaxFirstDayOfWeekPreference.system, + systemWeekday: DateTime.wednesday, + platformLocaleTag: 'en-US', + ), + DateTime.wednesday, + ); + }); + + test('locale fallback preserves region and Unicode fw overrides', () { + expect(busyMaxFirstWeekdayFromLocale('en-US'), DateTime.sunday); + expect(busyMaxFirstWeekdayFromLocale('de-DE'), DateTime.monday); + for (final entry in const { + 'mon': DateTime.monday, + 'tue': DateTime.tuesday, + 'wed': DateTime.wednesday, + 'thu': DateTime.thursday, + 'fri': DateTime.friday, + 'sat': DateTime.saturday, + 'sun': DateTime.sunday, + }.entries) { + expect( + busyMaxFirstWeekdayFromLocale('en-US-u-fw-${entry.key}'), + entry.value, + ); + } + expect( + resolveBusyMaxFirstWeekday( + preference: BusyMaxFirstDayOfWeekPreference.system, + systemWeekday: null, + platformLocaleTag: 'unknown', + ), + DateTime.monday, + ); + }); + + testWidgets('refresh ignores stale reads and changes after disposal', ( + tester, + ) async { + final source = _ControlledSource(); + final controller = BusyMaxSystemFirstWeekdayController(source); + final first = source.reads.single; + source.notify(); + await tester.pump(); + final second = source.reads.last; + first.complete(DateTime.tuesday); + await tester.pump(); + expect(controller.value, isNull); + second.complete(DateTime.saturday); + await tester.pump(); + expect(controller.value, DateTime.saturday); + + source.notify(); + await tester.pump(); + final afterDispose = source.reads.last; + controller.dispose(); + afterDispose.complete(DateTime.friday); + await tester.pump(); + expect(source.disposed, isTrue); + }); + + testWidgets('resume and locale changes refresh one application owner', ( + tester, + ) async { + final source = _ImmediateSource(DateTime.monday); + final controller = BusyMaxSystemFirstWeekdayController(source); + await tester.pump(); + expect(source.readCount, 1); + source.value = DateTime.thursday; + controller.didChangeAppLifecycleState(AppLifecycleState.resumed); + await tester.pump(); + expect(controller.value, DateTime.thursday); + controller.didChangeLocales(const [Locale('fr', 'CA')]); + await tester.pump(); + expect(source.readCount, 3); + controller.dispose(); + }); + + testWidgets('automatic label uses UI language and system region separately', ( + tester, + ) async { + await tester.pumpWidget( + MaterialApp( + locale: const Locale('de'), + localizationsDelegates: const [ + AppLocalizations.delegate, + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + ], + supportedLocales: const [Locale('de')], + builder: (context, child) => BusyMaxWeekPreferencesScope( + preference: BusyMaxFirstDayOfWeekPreference.system, + systemWeekday: DateTime.sunday, + platformLocaleTag: 'en-US', + child: child!, + ), + home: Builder( + builder: (context) => Text( + busyMaxFirstDayOfWeekPreferenceLabel( + context, + BusyMaxFirstDayOfWeekPreference.system, + ), + ), + ), + ), + ); + + expect(find.text('Systemstandard (Sonntag)'), findsOneWidget); + }); +} + +class _ControlledSource implements BusyMaxSystemFirstWeekdaySource { + final events = StreamController.broadcast(sync: true); + final reads = >[]; + bool disposed = false; + + void notify() => events.add(null); + + @override + Stream get changes => events.stream; + + @override + Future read() { + final result = Completer(); + reads.add(result); + return result.future; + } + + @override + void dispose() { + disposed = true; + unawaited(events.close()); + } +} + +class _ImmediateSource implements BusyMaxSystemFirstWeekdaySource { + _ImmediateSource(this.value); + + int? value; + int readCount = 0; + + @override + Stream get changes => const Stream.empty(); + + @override + Future read() async { + readCount++; + return value; + } + + @override + void dispose() {} +} diff --git a/test/app/native_ui_audit_test.dart b/test/app/native_ui_audit_test.dart index e51176dd..15ae0b1f 100644 --- a/test/app/native_ui_audit_test.dart +++ b/test/app/native_ui_audit_test.dart @@ -1988,8 +1988,8 @@ void main() { expect(source, contains('style_native_dialog(GtkWidget* dialog)')); expect( 'style_native_dialog(dialog);'.allMatches(source).length, - 2, - reason: 'native date and time pickers share dialog styling', + 1, + reason: 'the remaining native time picker uses dialog styling', ); expect(source, contains('style_native_dialog(window);')); expect( diff --git a/test/features/schedule/presentation/remembered_coordinate_details_test.dart b/test/features/schedule/presentation/remembered_coordinate_details_test.dart index 9ded52c8..07828b0a 100644 --- a/test/features/schedule/presentation/remembered_coordinate_details_test.dart +++ b/test/features/schedule/presentation/remembered_coordinate_details_test.dart @@ -40,9 +40,12 @@ void main() { final harness = await _importGeoOnlyGoogleEvent(); addTearDown(harness.database.close); - final item = (await ScheduleRepository( - harness.database, - ).listItems(range: ScheduleRange.week(DateTime.now()))).single; + final item = (await ScheduleRepository(harness.database).listItems( + range: ScheduleRange.week( + DateTime.now(), + firstWeekday: DateTime.monday, + ), + )).single; final resolved = await resolveSavedScheduleLocation( item: item, repository: LocationResolutionRepository(harness.database), diff --git a/test/features/schedule/presentation/schedule_search_filters_test.dart b/test/features/schedule/presentation/schedule_search_filters_test.dart index 2054c045..974d618a 100644 --- a/test/features/schedule/presentation/schedule_search_filters_test.dart +++ b/test/features/schedule/presentation/schedule_search_filters_test.dart @@ -148,22 +148,9 @@ void main() { expect(cleared, isTrue); }); - testWidgets('Linux custom range uses native desktop date fields', ( + testWidgets('Linux custom range uses the BusyMax calendar popover', ( tester, ) async { - const channel = MethodChannel(nativeDateTimePickerChannelName); - final calls = []; - final responses = ['2026-06-15', '2026-06-12', null]; - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, (call) async { - calls.add(call); - return responses.removeAt(0); - }); - addTearDown(() { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, null); - }); - final state = _FilterState( _criteria( date: ScheduleSearchDate.custom, @@ -177,7 +164,7 @@ void main() { tester.element(find.byType(ScheduleSearchFilters)), ); - Future pickDate(String label) async { + Future openPicker(String label) async { final field = find.byWidgetPredicate( (widget) => widget is DesktopDateField && widget.label == label, ); @@ -188,25 +175,38 @@ void main() { await tester.ensureVisible(button); await tester.tap(button); await tester.pumpAndSettle(); + expect(find.byType(BusyMaxContentPopoverSurface), findsOneWidget); + expect(find.byType(DatePickerDialog), findsNothing); + } + + Future pickDate(String label, int day) async { + await openPicker(label); + final popover = find.byType(BusyMaxContentPopoverSurface); + final dayButton = find.descendant( + of: popover, + matching: find.text('$day'), + ); + expect(dayButton, findsOneWidget); + await tester.tap(dayButton); + await tester.pumpAndSettle(); } - await pickDate(labels.startDate); - expect(calls.single.method, 'pickDate'); - expect(calls.single.arguments, containsPair('initialDate', '2026-06-12')); + await pickDate(labels.startDate, 15); expect(state.value.customStart, DateTime(2026, 6, 15)); expect(state.value.customEnd, DateTime(2026, 6, 15)); expect(state.value.range!.end, DateTime(2026, 6, 16)); - await pickDate(labels.endDate); - expect(calls.last.arguments, containsPair('initialDate', '2026-06-15')); + await pickDate(labels.endDate, 12); expect(state.value.customStart, DateTime(2026, 6, 12)); expect(state.value.customEnd, DateTime(2026, 6, 12)); expect(state.value.range!.end, DateTime(2026, 6, 13)); final beforeCancel = state.value; - await pickDate(labels.endDate); + await openPicker(labels.endDate); + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pumpAndSettle(); expect(state.value, beforeCancel); - expect(find.byType(DatePickerDialog), findsNothing); + expect(find.byType(BusyMaxContentPopoverSurface), findsNothing); expect(tester.takeException(), isNull); }); } diff --git a/test/features/schedule/presentation/schedule_toolbar_test.dart b/test/features/schedule/presentation/schedule_toolbar_test.dart index fd44ddf1..2adf6f94 100644 --- a/test/features/schedule/presentation/schedule_toolbar_test.dart +++ b/test/features/schedule/presentation/schedule_toolbar_test.dart @@ -49,7 +49,10 @@ void main() { width: 1200, child: ScheduleToolbar( mode: ScheduleViewMode.agenda, - range: ScheduleRange.week(DateTime(2026, 7, 22)), + range: ScheduleRange.week( + DateTime(2026, 7, 22), + firstWeekday: DateTime.monday, + ), selectedDate: DateTime(2026, 7, 22), onToday: () {}, onPrevious: () {}, @@ -123,7 +126,10 @@ void main() { width: 1000, child: ScheduleToolbar( mode: ScheduleViewMode.week, - range: ScheduleRange.week(DateTime(2026, 7, 22)), + range: ScheduleRange.week( + DateTime(2026, 7, 22), + firstWeekday: DateTime.monday, + ), selectedDate: DateTime(2026, 7, 22), onToday: () => activations++, onPrevious: () {}, @@ -169,7 +175,10 @@ void main() { width: 1000, child: ScheduleToolbar( mode: ScheduleViewMode.week, - range: ScheduleRange.week(DateTime(2026, 7, 22)), + range: ScheduleRange.week( + DateTime(2026, 7, 22), + firstWeekday: DateTime.monday, + ), selectedDate: DateTime(2026, 7, 22), onToday: () {}, onPrevious: () {}, @@ -226,7 +235,10 @@ void main() { width: 600, child: ScheduleToolbar( mode: ScheduleViewMode.week, - range: ScheduleRange.week(DateTime(2026, 7, 22)), + range: ScheduleRange.week( + DateTime(2026, 7, 22), + firstWeekday: DateTime.monday, + ), selectedDate: DateTime(2026, 7, 22), onToday: () {}, onPrevious: () {}, @@ -276,7 +288,10 @@ void main() { child: StatefulBuilder( builder: (context, setToolbarState) => ScheduleToolbar( mode: ScheduleViewMode.week, - range: ScheduleRange.week(DateTime(2026, 7, 22)), + range: ScheduleRange.week( + DateTime(2026, 7, 22), + firstWeekday: DateTime.monday, + ), selectedDate: DateTime(2026, 7, 22), onToday: () {}, onPrevious: () {}, @@ -436,7 +451,10 @@ void main() { width: 1000, child: ScheduleToolbar( mode: ScheduleViewMode.week, - range: ScheduleRange.week(DateTime(2026, 7, 22)), + range: ScheduleRange.week( + DateTime(2026, 7, 22), + firstWeekday: DateTime.monday, + ), selectedDate: DateTime(2026, 7, 22), onToday: () {}, onPrevious: () {}, @@ -498,7 +516,10 @@ void main() { width: 1000, child: ScheduleToolbar( mode: ScheduleViewMode.week, - range: ScheduleRange.week(DateTime(2026, 7, 22)), + range: ScheduleRange.week( + DateTime(2026, 7, 22), + firstWeekday: DateTime.monday, + ), selectedDate: DateTime(2026, 7, 22), onToday: () {}, onPrevious: () {}, @@ -578,7 +599,10 @@ void main() { width: 1000, child: ScheduleToolbar( mode: ScheduleViewMode.week, - range: ScheduleRange.week(DateTime(2026, 7, 22)), + range: ScheduleRange.week( + DateTime(2026, 7, 22), + firstWeekday: DateTime.monday, + ), selectedDate: DateTime(2026, 7, 22), onToday: () {}, onPrevious: () {}, @@ -627,7 +651,10 @@ void main() { Widget buildToolbar() { return ScheduleToolbar( mode: ScheduleViewMode.week, - range: ScheduleRange.week(DateTime(2026, 7, 22)), + range: ScheduleRange.week( + DateTime(2026, 7, 22), + firstWeekday: DateTime.monday, + ), selectedDate: DateTime(2026, 7, 22), onToday: () {}, onPrevious: () {}, @@ -701,7 +728,10 @@ void main() { width: 1000, child: ScheduleToolbar( mode: ScheduleViewMode.week, - range: ScheduleRange.week(DateTime(2026, 7, 22)), + range: ScheduleRange.week( + DateTime(2026, 7, 22), + firstWeekday: DateTime.monday, + ), selectedDate: DateTime(2026, 7, 22), onToday: () {}, onPrevious: () {}, diff --git a/test/features/schedule/presentation/schedule_views_test.dart b/test/features/schedule/presentation/schedule_views_test.dart index 16b5e96f..c62e0e78 100644 --- a/test/features/schedule/presentation/schedule_views_test.dart +++ b/test/features/schedule/presentation/schedule_views_test.dart @@ -45,7 +45,9 @@ import '../../../support/schedule_planner_gesture_suite.dart'; import '../../../support/schedule_date_gesture_suite.dart'; Widget _emptyPlanner(DateTime day, {required int days}) => ScheduleDayWeekView( - range: days == 1 ? ScheduleRange.day(day) : ScheduleRange.week(day), + range: days == 1 + ? ScheduleRange.day(day) + : ScheduleRange.week(day, firstWeekday: DateTime.monday), selectedDate: day, daysShowed: days, items: const [], @@ -80,7 +82,10 @@ void main() { (scenario) => localizedTestApp( child: Scaffold( body: ScheduleMonthView( - range: ScheduleRange.month(PlannerGestureScenario.day), + range: ScheduleRange.month( + PlannerGestureScenario.day, + firstWeekday: DateTime.monday, + ), selectedDate: PlannerGestureScenario.day, items: scenario.items, firstWeekday: DateTime.monday, @@ -104,7 +109,10 @@ void main() { child: ScheduleDayWeekView( range: scenario.days == 1 ? ScheduleRange.day(PlannerGestureScenario.day) - : ScheduleRange.week(PlannerGestureScenario.day), + : ScheduleRange.week( + PlannerGestureScenario.day, + firstWeekday: DateTime.monday, + ), selectedDate: PlannerGestureScenario.day, daysShowed: scenario.days, items: scenario.items, @@ -463,7 +471,10 @@ void main() { child: ScheduleDayWeekView( range: configuration.daysShowed == 1 ? ScheduleRange.day(selectedDate) - : ScheduleRange.week(selectedDate), + : ScheduleRange.week( + selectedDate, + firstWeekday: DateTime.monday, + ), selectedDate: selectedDate, daysShowed: configuration.daysShowed, items: _itemsFor(selectedDate), @@ -523,7 +534,10 @@ void main() { width: 1000, height: 720, child: ScheduleDayWeekView( - range: ScheduleRange.week(selectedDate), + range: ScheduleRange.week( + selectedDate, + firstWeekday: DateTime.monday, + ), selectedDate: selectedDate, daysShowed: 7, items: _itemsFor(selectedDate), @@ -568,7 +582,10 @@ void main() { width: 1000, height: 720, child: ScheduleMonthView( - range: ScheduleRange.month(selectedDate), + range: ScheduleRange.month( + selectedDate, + firstWeekday: DateTime.monday, + ), selectedDate: selectedDate, items: _itemsFor(selectedDate), firstWeekday: DateTime.monday, @@ -712,7 +729,10 @@ void main() { width: 1000, height: 720, child: ScheduleMonthView( - range: ScheduleRange.month(selectedDate), + range: ScheduleRange.month( + selectedDate, + firstWeekday: DateTime.monday, + ), selectedDate: selectedDate, firstWeekday: DateTime.monday, items: const [], @@ -1072,7 +1092,10 @@ void main() { width: 1000, height: 720, child: ScheduleMonthView( - range: ScheduleRange.month(selectedDate), + range: ScheduleRange.month( + selectedDate, + firstWeekday: DateTime.monday, + ), selectedDate: selectedDate, firstWeekday: DateTime.monday, items: _itemsFor(selectedDate), @@ -1105,7 +1128,10 @@ void main() { width: 1000, height: 720, child: ScheduleMonthView( - range: ScheduleRange.month(selectedDate), + range: ScheduleRange.month( + selectedDate, + firstWeekday: DateTime.monday, + ), selectedDate: selectedDate, firstWeekday: DateTime.monday, items: const [], @@ -1169,7 +1195,10 @@ void main() { width: 1000, height: 720, child: ScheduleMonthView( - range: ScheduleRange.month(selectedDate), + range: ScheduleRange.month( + selectedDate, + firstWeekday: DateTime.monday, + ), selectedDate: selectedDate, firstWeekday: DateTime.monday, items: const [], @@ -1308,7 +1337,10 @@ void main() { width: 700, height: 120, child: ScheduleMonthView( - range: ScheduleRange.month(selectedDate), + range: ScheduleRange.month( + selectedDate, + firstWeekday: DateTime.monday, + ), selectedDate: selectedDate, firstWeekday: DateTime.monday, items: _sameSlotItemsFor(selectedDate), @@ -1339,7 +1371,10 @@ void main() { width: 700, height: 720, child: ScheduleMonthView( - range: ScheduleRange.month(selectedDate), + range: ScheduleRange.month( + selectedDate, + firstWeekday: DateTime.monday, + ), selectedDate: selectedDate, firstWeekday: DateTime.monday, items: _manyAllDayItemsFor(selectedDate), @@ -2855,7 +2890,10 @@ void main() { localizedTestApp( child: Scaffold( body: ScheduleAgendaView( - range: ScheduleRange.week(criteria.referenceDate), + range: ScheduleRange.week( + criteria.referenceDate, + firstWeekday: DateTime.monday, + ), items: [undatedTask, startFallbackTask, dueFirstTask], searchCriteria: criteria, searchQuery: 'task', @@ -3128,7 +3166,10 @@ void main() { width: 800, height: 700, child: ScheduleAgendaView( - range: ScheduleRange.week(selectedDate), + range: ScheduleRange.week( + selectedDate, + firstWeekday: DateTime.monday, + ), items: const [ googleGrandchild, googleChild, @@ -3242,7 +3283,10 @@ void main() { width: 800, height: 700, child: ScheduleAgendaView( - range: ScheduleRange.week(selectedDate), + range: ScheduleRange.week( + selectedDate, + firstWeekday: DateTime.monday, + ), items: const [firstChild, secondChild], onItemSelected: (_, _, [_]) {}, onTaskCompletionChanged: (_, _) {}, @@ -3366,7 +3410,10 @@ void main() { width: 1000, height: 720, child: ScheduleAgendaView( - range: ScheduleRange.week(selectedDate), + range: ScheduleRange.week( + selectedDate, + firstWeekday: DateTime.monday, + ), items: const [], onItemSelected: (_, _, [_]) {}, onTaskCompletionChanged: (_, _) {}, @@ -3394,7 +3441,10 @@ void main() { width: 1000, height: 720, child: ScheduleAgendaView( - range: ScheduleRange.week(selectedDate), + range: ScheduleRange.week( + selectedDate, + firstWeekday: DateTime.monday, + ), items: _itemsFor(selectedDate), onItemSelected: (_, _, [_]) {}, onItemAnchorAvailable: (item, context) { diff --git a/test/features/schedule/schedule_dst_test.dart b/test/features/schedule/schedule_dst_test.dart index 9d31db7d..9245eb4b 100644 --- a/test/features/schedule/schedule_dst_test.dart +++ b/test/features/schedule/schedule_dst_test.dart @@ -9,6 +9,27 @@ import '../../test_localized_app.dart'; import '../../support/process_time_zone.dart'; void main() { + test('week and month boundaries support every first weekday', () { + final anchor = DateTime(2028, 2, 29); + for ( + var firstWeekday = DateTime.monday; + firstWeekday <= DateTime.sunday; + firstWeekday++ + ) { + final week = ScheduleRange.week(anchor, firstWeekday: firstWeekday); + final month = ScheduleRange.month(anchor, firstWeekday: firstWeekday); + expect(week.start.weekday, firstWeekday); + expect( + week.end, + DateTime(week.start.year, week.start.month, week.start.day + 7), + ); + expect(month.start.weekday, firstWeekday); + expect(month.end.weekday, firstWeekday); + expect(month.start.isAfter(DateTime(2028, 2, 1)), isFalse); + expect(month.end.isAfter(DateTime(2028, 2, 29)), isTrue); + } + }); + final supportsPosixTimeZones = Platform.isLinux || Platform.isMacOS; group( @@ -61,7 +82,10 @@ void main() { }); test('month range retains civil midnight across its trailing days', () { - final range = ScheduleRange.month(DateTime(2025, 10, 15)); + final range = ScheduleRange.month( + DateTime(2025, 10, 15), + firstWeekday: DateTime.monday, + ); expect(range.start, DateTime(2025, 9, 29)); expect(range.end, DateTime(2025, 11, 3)); diff --git a/test/features/schedule/schedule_search_test.dart b/test/features/schedule/schedule_search_test.dart index bbd65251..43ee3d81 100644 --- a/test/features/schedule/schedule_search_test.dart +++ b/test/features/schedule/schedule_search_test.dart @@ -67,6 +67,32 @@ void main() { bool matches(ScheduleItem item, ScheduleFilters filters) => matchesScheduleFilters(item, filters, range: range); + test('copyWith changes week boundary without losing other filters', () { + final original = ScheduleSearchCriteria( + type: ScheduleSearchType.tasks, + date: ScheduleSearchDate.thisWeek, + taskCompletion: ScheduleTaskCompletion.completed, + person: 'Alex', + location: 'Office', + referenceDate: today, + firstWeekday: DateTime.monday, + sourceIds: const {'calendar'}, + taskListKeys: { + const ScheduleTaskListKey(accountId: 'account', taskListId: 'list'), + }, + ); + final changed = original.copyWith(firstWeekday: DateTime.saturday); + + expect(changed.range?.start, DateTime(2026, 6, 6)); + expect(changed.firstWeekday, DateTime.saturday); + expect(changed.type, original.type); + expect(changed.taskCompletion, original.taskCompletion); + expect(changed.person, original.person); + expect(changed.location, original.location); + expect(changed.sourceIds, original.sourceIds); + expect(changed.taskListKeys, original.taskListKeys); + }); + for (final term in [ 'Alex Smith', 'alex@example.com', diff --git a/test/features/settings/presentation/settings_screen_test.dart b/test/features/settings/presentation/settings_screen_test.dart index 353b38ac..5244a7d2 100644 --- a/test/features/settings/presentation/settings_screen_test.dart +++ b/test/features/settings/presentation/settings_screen_test.dart @@ -1223,6 +1223,38 @@ void main() { expect(second.state.scheduleDayEndMinute, 24 * 60); }); + testWidgets('Linux schedule settings exposes and persists first weekday', ( + tester, + ) async { + final container = _container( + selectedAccountId: 'google:g', + authRepository: _FakeAuthRepository(), + accounts: const [_googleAccount], + ); + addTearDown(container.dispose); + await _pumpSettings( + tester, + container, + initialPage: SettingsPage.schedule, + logicalSize: const Size(1000, 800), + ); + + final row = tester.widget>( + find.byWidgetPredicate( + (widget) => widget is BusyMaxComboRow, + ), + ); + expect(row.values, BusyMaxFirstDayOfWeekPreference.values); + expect(row.selected, BusyMaxFirstDayOfWeekPreference.system); + expect(row.labelFor(row.selected), startsWith('System default (')); + row.onSelected(BusyMaxFirstDayOfWeekPreference.saturday); + await tester.pumpAndSettle(); + expect( + container.read(appSettingsControllerProvider).firstDayOfWeekPreference, + BusyMaxFirstDayOfWeekPreference.saturday, + ); + }); + testWidgets('Settings owns calendar import but not task-list creation', ( tester, ) async { diff --git a/test/features/tasks/presentation/desktop_date_time_fields_test.dart b/test/features/tasks/presentation/desktop_date_time_fields_test.dart index fdc03b26..17783614 100644 --- a/test/features/tasks/presentation/desktop_date_time_fields_test.dart +++ b/test/features/tasks/presentation/desktop_date_time_fields_test.dart @@ -889,7 +889,7 @@ void main() { }); testWidgets( - 'fallback date dialog safely bounds an unsupported initial date', + 'BusyMax date popover preserves dates accepted by the former native path', (tester) async { late BuildContext hostContext; await tester.pumpWidget( @@ -910,7 +910,7 @@ void main() { ); await tester.pumpAndSettle(); - expect(find.byTooltip('Friday, December 31, 2100'), findsOneWidget); + expect(find.byTooltip('Wednesday, January 1, 2200'), findsOneWidget); expect(tester.takeException(), isNull); await tester.sendKeyEvent(LogicalKeyboardKey.escape); diff --git a/test/features/tasks/presentation/task_details_pane_test.dart b/test/features/tasks/presentation/task_details_pane_test.dart index ede5e7f0..20c6ebbc 100644 --- a/test/features/tasks/presentation/task_details_pane_test.dart +++ b/test/features/tasks/presentation/task_details_pane_test.dart @@ -1621,7 +1621,6 @@ void main() { body: DesktopDateValueRow( label: 'Due date', date: '2026-06-06', - useNativePicker: false, onChanged: (date) => changed = date, ), ), @@ -1664,7 +1663,6 @@ void main() { body: DesktopDateValueRow( label: 'Due date', date: null, - useNativePicker: false, onChanged: (date) => changed = date, ), ), diff --git a/test/platform/android/android_adaptive_app_test.dart b/test/platform/android/android_adaptive_app_test.dart index fa4c44ba..4de6d9f3 100644 --- a/test/platform/android/android_adaptive_app_test.dart +++ b/test/platform/android/android_adaptive_app_test.dart @@ -163,6 +163,40 @@ void main() { expect(tester.takeException(), isNull); }); + testWidgets('Android Settings fits and persists first weekday', ( + tester, + ) async { + tester.view.physicalSize = const Size(360, 800); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + final harness = await _pumpApp(tester, AppSettings.defaults()); + addTearDown(harness.dispose); + + await tester.tap(find.text('Settings').last); + await tester.pumpAndSettle(); + final label = find.text('First day of the week'); + await _scrollUntilBuilt(tester, label); + expect(label, findsOneWidget); + final dropdown = tester + .widget>( + find.byWidgetPredicate( + (widget) => + widget is DropdownButton, + ), + ); + expect(dropdown.isExpanded, isTrue); + dropdown.onChanged!(BusyMaxFirstDayOfWeekPreference.thursday); + await tester.pumpAndSettle(); + expect( + harness.container + .read(appSettingsControllerProvider) + .firstDayOfWeekPreference, + BusyMaxFirstDayOfWeekPreference.thursday, + ); + expect(tester.takeException(), isNull); + }); + testWidgets('required Android viewport matrix renders every destination', ( tester, ) async { @@ -232,7 +266,7 @@ void main() { expect(find.text('Hidden event'), findsNothing); expect(find.text('Hidden task'), findsNothing); expect(tester.takeException(), isNull); - await tester.tap(find.textContaining('more').first); + await tester.tap(find.textContaining('more').hitTestable().first); await tester.pumpAndSettle(); await tester.tap(find.text('Invitation event')); await tester.pumpAndSettle(); @@ -241,7 +275,7 @@ void main() { expect(find.text('Decline'), findsOneWidget); tester.state(find.byType(Navigator).first).pop(); await tester.pumpAndSettle(); - await tester.tap(find.textContaining('more').first); + await tester.tap(find.textContaining('more').hitTestable().first); await tester.pumpAndSettle(); expect(find.text('All-day event 3'), findsOneWidget); expect(find.text('Date-only task'), findsOneWidget); @@ -262,7 +296,10 @@ void main() { expect(find.textContaining('Overnight event'), findsWidgets); expect(find.textContaining('more'), findsWidgets); expect(tester.takeException(), isNull); - await tester.tap(find.textContaining('more').first); + final compactWeekMore = find.textContaining('more').first; + await tester.ensureVisible(compactWeekMore); + await tester.pumpAndSettle(); + await tester.tap(compactWeekMore); await tester.pumpAndSettle(); expect(find.text('Date-only task'), findsOneWidget); tester.state(find.byType(Navigator).first).pop(); @@ -275,7 +312,7 @@ void main() { find.byKey(const ValueKey('android-week-time-grid')), findsOneWidget, ); - await tester.tap(find.textContaining('more').first); + await tester.tap(find.textContaining('more').hitTestable().first); await tester.pumpAndSettle(); expect(find.text('Date-only task'), findsOneWidget); tester.state(find.byType(Navigator).first).pop(); diff --git a/test/platform/android/android_date_picker_test.dart b/test/platform/android/android_date_picker_test.dart new file mode 100644 index 00000000..c02b8699 --- /dev/null +++ b/test/platform/android/android_date_picker_test.dart @@ -0,0 +1,109 @@ +import 'package:busymax/src/android/presentation/android_date_picker.dart'; +import 'package:busymax/src/l10n/week_preferences_scope.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets( + 'open picker follows preference changes without losing selection', + (tester) async { + final preference = ValueNotifier(BusyMaxFirstDayOfWeekPreference.monday); + addTearDown(preference.dispose); + DateTime? result; + + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: const [ + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + ], + supportedLocales: const [Locale('en')], + builder: (context, child) => ValueListenableBuilder( + valueListenable: preference, + builder: (context, value, _) => BusyMaxWeekPreferencesScope( + preference: value, + systemWeekday: DateTime.sunday, + platformLocaleTag: 'en-US', + child: child!, + ), + ), + home: Builder( + builder: (context) => TextButton( + onPressed: () async { + result = await showBusyMaxDatePicker( + context: context, + initialDate: DateTime(2026, 9, 15), + firstDate: DateTime(2026), + lastDate: DateTime(2026, 12, 31), + ); + }, + child: const Text('Open'), + ), + ), + ), + ); + + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + BuildContext pickerContext = tester.element( + find.byType(DatePickerDialog), + ); + expect( + MaterialLocalizations.of(pickerContext).firstDayOfWeekIndex, + DateTime.monday, + ); + + await tester.tap(find.text('17')); + await tester.pump(); + preference.value = BusyMaxFirstDayOfWeekPreference.saturday; + await tester.pumpAndSettle(); + pickerContext = tester.element(find.byType(DatePickerDialog)); + expect( + MaterialLocalizations.of(pickerContext).firstDayOfWeekIndex, + DateTime.saturday, + ); + + await tester.tap(find.text('OK')); + await tester.pumpAndSettle(); + expect(result, DateTime(2026, 9, 17)); + }, + ); + + testWidgets('picker preserves input mode, limits, and cancellation', ( + tester, + ) async { + DateTime? result = DateTime(2000); + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: const [ + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + ], + home: Builder( + builder: (context) => TextButton( + onPressed: () async { + result = await showBusyMaxDatePicker( + context: context, + initialDate: DateTime(2026, 9, 15), + firstDate: DateTime(2026, 9, 10), + lastDate: DateTime(2026, 9, 20), + initialEntryMode: DatePickerEntryMode.input, + ); + }, + child: const Text('Open'), + ), + ), + ), + ); + + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + expect(find.byType(TextField), findsOneWidget); + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + expect(result, isNull); + }); +} diff --git a/test/platform/android/android_settings_persistence_test.dart b/test/platform/android/android_settings_persistence_test.dart index 62d2dc91..0fed9b7a 100644 --- a/test/platform/android/android_settings_persistence_test.dart +++ b/test/platform/android/android_settings_persistence_test.dart @@ -23,4 +23,15 @@ void main() { expect(restored.androidScheduleViewMode, ScheduleViewMode.month); expect(restored.scheduleViewMode, ScheduleViewMode.week); }); + + test('Android first-day preference is device-local and round-trips', () { + for (final preference in BusyMaxFirstDayOfWeekPreference.values) { + final restored = AppSettings.fromJson( + AppSettings.defaults() + .copyWith(firstDayOfWeekPreference: preference) + .toJson(), + ); + expect(restored.firstDayOfWeekPreference, preference); + } + }); } diff --git a/test/platform/windows/windows_clock_preference_test.dart b/test/platform/windows/windows_clock_preference_test.dart index a5dfe137..0de43ca1 100644 --- a/test/platform/windows/windows_clock_preference_test.dart +++ b/test/platform/windows/windows_clock_preference_test.dart @@ -1,4 +1,6 @@ import 'package:busymax/src/platform/windows/windows_clock_preference.dart'; +import 'package:busymax/src/l10n/week_preferences_scope.dart'; +import 'package:busymax/src/platform/windows/windows_first_weekday_source.dart'; import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -40,4 +42,41 @@ void main() { expect(changes, [false, true, false]); }, ); + + test( + 'Windows weekday uses a separate channel and refresh lifecycle', + () async { + const channel = MethodChannel('busymax/windows_weekday'); + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + var system = DateTime.wednesday; + messenger.setMockMethodCallHandler(channel, (call) async { + expect(call.method, 'getFirstWeekday'); + return system; + }); + addTearDown(() => messenger.setMockMethodCallHandler(channel, null)); + final controller = BusyMaxSystemFirstWeekdayController( + WindowsFirstWeekdaySource(), + ); + addTearDown(controller.dispose); + await Future.delayed(Duration.zero); + expect(controller.value, DateTime.wednesday); + + system = DateTime.sunday; + await messenger.handlePlatformMessage( + channel.name, + const StandardMethodCodec().encodeMethodCall( + const MethodCall('weekdayChanged', DateTime.sunday), + ), + (_) {}, + ); + await Future.delayed(Duration.zero); + expect(controller.value, DateTime.sunday); + + system = DateTime.friday; + controller.didChangeAppLifecycleState(AppLifecycleState.resumed); + await Future.delayed(Duration.zero); + expect(controller.value, DateTime.friday); + }, + ); } diff --git a/test/platform/windows/windows_schedule_source_pane_test.dart b/test/platform/windows/windows_schedule_source_pane_test.dart index 01d3afc8..bfc290f1 100644 --- a/test/platform/windows/windows_schedule_source_pane_test.dart +++ b/test/platform/windows/windows_schedule_source_pane_test.dart @@ -282,6 +282,7 @@ void main() { width: 360, height: 720, child: WindowsScheduleSourcePane( + firstWeekday: DateTime.monday, selectedDate: DateTime(2026, 8, 31), accounts: const [account], calendarSources: const [source], @@ -344,6 +345,7 @@ Future _pumpPane( width: 360, height: 1100, child: WindowsScheduleSourcePane( + firstWeekday: DateTime.monday, selectedDate: DateTime(2026, 9, 5), accounts: accounts, calendarSources: calendars, diff --git a/test/platform/windows/windows_settings_startup_test.dart b/test/platform/windows/windows_settings_startup_test.dart index 5b5a78ab..bc2e8676 100644 --- a/test/platform/windows/windows_settings_startup_test.dart +++ b/test/platform/windows/windows_settings_startup_test.dart @@ -140,6 +140,26 @@ void main() { await tester.pumpAndSettle(); expect(tester.widget(_switch).onChanged, isNotNull); }); + + testWidgets('Windows Settings persists the first weekday selection', ( + tester, + ) async { + final container = _container(FakeAutostartService()); + addTearDown(container.dispose); + await _pumpSettings(tester, container); + final combo = tester.widget>( + find.byWidgetPredicate( + (widget) => widget is ComboBox, + ), + ); + expect(combo.value, BusyMaxFirstDayOfWeekPreference.system); + combo.onChanged!(BusyMaxFirstDayOfWeekPreference.wednesday); + await tester.pumpAndSettle(); + expect( + container.read(appSettingsControllerProvider).firstDayOfWeekPreference, + BusyMaxFirstDayOfWeekPreference.wednesday, + ); + }); } final _switch = find.byKey(const ValueKey('launch-at-login-switch')); diff --git a/test/ui/windows/windows_calendar_overview_test.dart b/test/ui/windows/windows_calendar_overview_test.dart index 5e5d4f40..842cf3ce 100644 --- a/test/ui/windows/windows_calendar_overview_test.dart +++ b/test/ui/windows/windows_calendar_overview_test.dart @@ -20,8 +20,9 @@ void main() { await tester.pumpWidget( _app( WindowsScheduleMonthView( + firstWeekday: DateTime.monday, selectedDate: _day, - range: ScheduleRange.month(_day), + range: ScheduleRange.month(_day, firstWeekday: DateTime.monday), items: items, locale: 'en', onOpen: (item) => opened = item, @@ -82,8 +83,9 @@ void main() { child: SizedBox( height: height, child: WindowsScheduleMonthView( + firstWeekday: DateTime.monday, selectedDate: _day, - range: ScheduleRange.month(_day), + range: ScheduleRange.month(_day, firstWeekday: DateTime.monday), items: items, locale: 'en', onOpen: (_) {}, @@ -112,6 +114,7 @@ void main() { await tester.pumpWidget( _app( WindowsScheduleYearView( + firstWeekday: DateTime.monday, selectedDate: DateTime(2026, 1, 1), items: [ _event( diff --git a/test/ui/windows/windows_schedule_planner_test.dart b/test/ui/windows/windows_schedule_planner_test.dart index e63e597a..f1a2abd4 100644 --- a/test/ui/windows/windows_schedule_planner_test.dart +++ b/test/ui/windows/windows_schedule_planner_test.dart @@ -84,8 +84,12 @@ void main() { supportedLocales: AppLocalizations.supportedLocales, home: ScaffoldPage( content: WindowsScheduleMonthView( + firstWeekday: DateTime.monday, selectedDate: PlannerGestureScenario.day, - range: ScheduleRange.month(PlannerGestureScenario.day), + range: ScheduleRange.month( + PlannerGestureScenario.day, + firstWeekday: DateTime.monday, + ), items: scenario.items, locale: 'en', onOpen: scenario.opened.add, diff --git a/windows/runner/flutter_window.cpp b/windows/runner/flutter_window.cpp index 664a778a..7b192263 100644 --- a/windows/runner/flutter_window.cpp +++ b/windows/runner/flutter_window.cpp @@ -1,5 +1,6 @@ #include "flutter_window.h" #include "clock_preference.h" +#include "weekday_preference.h" #include #include @@ -79,6 +80,24 @@ bool FlutterWindow::OnCreate() { } }); + weekday_channel_ = + std::make_unique>( + flutter_controller_->engine()->messenger(), "busymax/windows_weekday", + &flutter::StandardMethodCodec::GetInstance()); + RefreshFirstWeekday(); + weekday_channel_->SetMethodCallHandler([this](const auto& call, auto result) { + if (call.method_name() != "getFirstWeekday") { + result->NotImplemented(); + return; + } + RefreshFirstWeekday(); + if (first_weekday_) { + result->Success(flutter::EncodableValue(*first_weekday_)); + } else { + result->Success(); + } + }); + desktop_channel_ = std::make_unique>( flutter_controller_->engine()->messenger(), kDesktopChannel, @@ -250,6 +269,7 @@ void FlutterWindow::QueueActivation(std::string activation) { void FlutterWindow::OnDestroy() { clock_channel_.reset(); + weekday_channel_.reset(); if (flutter_controller_) { flutter_controller_ = nullptr; } @@ -264,6 +284,7 @@ FlutterWindow::MessageHandler(HWND hwnd, UINT const message, // Observe before plugin processing, but retain all existing message handling. if (BusyMaxClockRefreshMessage(message, wparam, lparam)) { RefreshClockPreference(); + RefreshFirstWeekday(); } // Give Flutter, including plugins, an opportunity to handle window messages. if (flutter_controller_) { @@ -328,3 +349,17 @@ void FlutterWindow::RefreshClockPreference() { std::make_unique(current)); } } + +void FlutterWindow::RefreshFirstWeekday() { + const auto current = BusyMaxReadFirstWeekday(); + if (current == first_weekday_) return; + first_weekday_ = current; + if (!weekday_channel_) return; + if (current) { + weekday_channel_->InvokeMethod( + "weekdayChanged", + std::make_unique(*current)); + } else { + weekday_channel_->InvokeMethod("weekdayChanged", nullptr); + } +} diff --git a/windows/runner/flutter_window.h b/windows/runner/flutter_window.h index 248ae277..85a56343 100644 --- a/windows/runner/flutter_window.h +++ b/windows/runner/flutter_window.h @@ -8,6 +8,7 @@ #include #include #include +#include #include #include @@ -41,8 +42,12 @@ class FlutterWindow : public Win32Window { std::unique_ptr> desktop_channel_; std::unique_ptr> clock_channel_; + std::unique_ptr> + weekday_channel_; bool uses_24_hour_clock_ = false; + std::optional first_weekday_; void RefreshClockPreference(); + void RefreshFirstWeekday(); std::mutex activation_mutex_; std::vector pending_activations_; bool activation_ready_ = false; diff --git a/windows/runner/tests/single_instance_test.cpp b/windows/runner/tests/single_instance_test.cpp index 6b5f021c..9e8a06e1 100644 --- a/windows/runner/tests/single_instance_test.cpp +++ b/windows/runner/tests/single_instance_test.cpp @@ -1,5 +1,6 @@ #include "../single_instance.h" #include "../clock_preference.h" +#include "../weekday_preference.h" #include @@ -74,6 +75,35 @@ void TestPreferredShortTimeClock() { fail_locale_read = false; } +DWORD test_first_weekday = 0; + +int WINAPI ReadTestFirstWeekday(LPCWSTR locale, LCTYPE type, LPWSTR output, + int size) { + Check(locale == LOCALE_NAME_USER_DEFAULT, "weekday reads the user locale"); + Check(type == (LOCALE_IFIRSTDAYOFWEEK | LOCALE_RETURN_NUMBER), + "weekday requests the numeric user override"); + const int required = static_cast(sizeof(DWORD) / sizeof(wchar_t)); + if (fail_locale_read || output == nullptr || size != required) return 0; + *reinterpret_cast(output) = test_first_weekday; + return required; +} + +void TestFirstWeekday() { + for (DWORD windows_weekday = 0; windows_weekday < 7; windows_weekday++) { + test_first_weekday = windows_weekday; + Check(BusyMaxReadFirstWeekday(&ReadTestFirstWeekday) == + static_cast(windows_weekday) + 1, + "Windows weekday converts to Dart numbering"); + } + test_first_weekday = 7; + Check(!BusyMaxReadFirstWeekday(&ReadTestFirstWeekday).has_value(), + "invalid Windows weekday is unavailable"); + fail_locale_read = true; + Check(!BusyMaxReadFirstWeekday(&ReadTestFirstWeekday).has_value(), + "weekday read failure is unavailable"); + fail_locale_read = false; +} + void TestActivationValidation() { Check(BuildBusyMaxActivation({"----AppNotificationActivationServer"}) == R"({"version":1,"kind":"startMinimized"})", @@ -271,6 +301,7 @@ void TestForwardingTimeoutIsBounded() { int main() { TestPreferredShortTimeClock(); + TestFirstWeekday(); Check(BusyMaxRead24HourClock().has_value(), "user clock preference can be read"); Check(BusyMaxClockRefreshMessage(WM_SETTINGCHANGE, 0, reinterpret_cast(L"intl")), "locale settings refresh the clock"); Check(BusyMaxClockRefreshMessage(WM_SETTINGCHANGE, 0, 0), "unspecified settings refresh the clock"); diff --git a/windows/runner/weekday_preference.h b/windows/runner/weekday_preference.h new file mode 100644 index 00000000..a0867723 --- /dev/null +++ b/windows/runner/weekday_preference.h @@ -0,0 +1,21 @@ +#ifndef BUSYMAX_WEEKDAY_PREFERENCE_H_ +#define BUSYMAX_WEEKDAY_PREFERENCE_H_ + +#include + +#include + +// Reads the current user's regional override. Windows numbers Monday as zero. +inline std::optional BusyMaxReadFirstWeekday( + decltype(&GetLocaleInfoEx) read_locale = &GetLocaleInfoEx) { + DWORD windows_weekday = 0; + const int copied = read_locale( + LOCALE_NAME_USER_DEFAULT, + LOCALE_IFIRSTDAYOFWEEK | LOCALE_RETURN_NUMBER, + reinterpret_cast(&windows_weekday), + static_cast(sizeof(windows_weekday) / sizeof(wchar_t))); + if (copied == 0 || windows_weekday > 6) return std::nullopt; + return static_cast(windows_weekday) + 1; +} + +#endif // BUSYMAX_WEEKDAY_PREFERENCE_H_ From c4f1bde2bc91445069277f418bcd2d1ae05c9a72 Mon Sep 17 00:00:00 2001 From: albert Date: Fri, 18 Sep 2026 17:20:05 -0700 Subject: [PATCH 16/25] Fix Microsoft task conflict --- lib/src/db/daos/pending_ops_dao.dart | 2 + lib/src/features/sync/conflict_detector.dart | 4 +- .../sync/pending_op_resolution_service.dart | 14 + .../features/sync/pending_ops_replayer.dart | 137 +++++++-- .../tasks/domain/task_remote_client.dart | 15 + .../microsoft_todo_task_remote_client.dart | 94 +++++- .../pending_op_resolution_service_test.dart | 28 ++ .../sync/pending_ops_replayer_test.dart | 277 +++++++++++++++++- 8 files changed, 539 insertions(+), 32 deletions(-) diff --git a/lib/src/db/daos/pending_ops_dao.dart b/lib/src/db/daos/pending_ops_dao.dart index cf7d27b8..bbc32f39 100644 --- a/lib/src/db/daos/pending_ops_dao.dart +++ b/lib/src/db/daos/pending_ops_dao.dart @@ -124,6 +124,7 @@ class PendingOpsDao extends DatabaseAccessor required DateTime nextAttemptAtUtc, String? lastErrorCode, String? lastErrorMessage, + String? state, }) { final query = update(pendingOps)..where((row) => row.id.equals(id)); return query.write( @@ -132,6 +133,7 @@ class PendingOpsDao extends DatabaseAccessor nextAttemptAtUtc: Value(nextAttemptAtUtc.toIso8601String()), lastErrorCode: Value(lastErrorCode), lastErrorMessage: Value(lastErrorMessage), + state: state == null ? const Value.absent() : Value(state), updatedAtUtc: Value(DateTime.now().toUtc().toIso8601String()), ), ); diff --git a/lib/src/features/sync/conflict_detector.dart b/lib/src/features/sync/conflict_detector.dart index bba47702..e429d57e 100644 --- a/lib/src/features/sync/conflict_detector.dart +++ b/lib/src/features/sync/conflict_detector.dart @@ -1,3 +1,5 @@ +import 'package:collection/collection.dart'; + import '../../google_tasks/api/google_tasks_json.dart'; import 'conflict_models.dart'; @@ -27,7 +29,7 @@ class ConflictDetector { for (final field in localPendingFields.keys) { final lastValue = _comparableValue(field, lastServerJson[field]); final currentValue = _comparableValue(field, currentServerJson[field]); - if (lastValue != currentValue) { + if (!const DeepCollectionEquality().equals(lastValue, currentValue)) { overlapping.add(field); } } diff --git a/lib/src/features/sync/pending_op_resolution_service.dart b/lib/src/features/sync/pending_op_resolution_service.dart index 89430064..c4ad438d 100644 --- a/lib/src/features/sync/pending_op_resolution_service.dart +++ b/lib/src/features/sync/pending_op_resolution_service.dart @@ -43,6 +43,14 @@ class PendingOpResolutionService { final op = await _database.pendingOpsDao.getOp(opId); if (op == null) return; _requireOwnedOperation(op); + if (op.state == 'recovery_required' && + _isTaskCreationOperation(op.operation)) { + throw StateError( + 'This creation cannot be retried safely because the provider may ' + 'already have created the item. Check the provider, then discard this ' + 'operation or resolve the duplicate manually.', + ); + } if (isDavPendingOperation(op)) { await DavPendingOperationQueue( database: _database, @@ -54,6 +62,12 @@ class PendingOpResolutionService { await _syncAfterResolution(op); } + bool _isTaskCreationOperation(String operation) { + return operation == 'create_task_list' || + operation == 'create_task' || + operation == 'create_task_checklist_item'; + } + Future discard(String opId) async { final op = await _database.pendingOpsDao.getOp(opId); if (op == null) { diff --git a/lib/src/features/sync/pending_ops_replayer.dart b/lib/src/features/sync/pending_ops_replayer.dart index f0d4b3a5..69e924fa 100644 --- a/lib/src/features/sync/pending_ops_replayer.dart +++ b/lib/src/features/sync/pending_ops_replayer.dart @@ -68,8 +68,27 @@ class PendingOpsReplayer { if (op.dependsOnOpId != null && await _opExists(op.dependsOnOpId!)) { continue; } - if (op.operation == 'create_task' && !await _claimTaskCreate(op)) { - continue; + if (_isCreationOp(op)) { + if (op.state == 'in_progress') { + await _blockUnknownCreationOutcome( + op, + 'The app stopped before the result of this creation request ' + 'was recorded. Check the provider before discarding the ' + 'local operation.', + ); + handledIds.add(op.id); + madeProgress = true; + continue; + } + if (op.state == 'recovery_required') { + await _restoreUnknownCreationOutcomeBlock(op); + handledIds.add(op.id); + madeProgress = true; + continue; + } + if (!await _claimCreation(op)) { + continue; + } } handledIds.add(op.id); madeProgress = true; @@ -83,6 +102,9 @@ class PendingOpsReplayer { await _applyDeleteSideEffect(op); await _database.pendingOpsDao.deleteOp(op.id); applied += 1; + } else if (_isCreationOp(op) && + _hasUnknownCreationOutcome(error.statusCode)) { + await _blockUnknownCreationOutcome(op, error.message); } else if (_isRetryableStatus(error.statusCode)) { await _scheduleRetry( op, @@ -95,11 +117,15 @@ class PendingOpsReplayer { } on _PendingOpBlocked { continue; } on Object catch (error) { - await _scheduleRetry( - op, - error.runtimeType.toString(), - error.toString(), - ); + if (_isCreationOp(op)) { + await _blockUnknownCreationOutcome(op, error.toString()); + } else { + await _scheduleRetry( + op, + error.runtimeType.toString(), + error.toString(), + ); + } } } } @@ -113,6 +139,12 @@ class PendingOpsReplayer { op.entityType == 'task_checklist_item'; } + bool _isCreationOp(PendingOp op) { + return op.operation == 'create_task_list' || + op.operation == 'create_task' || + op.operation == 'create_task_checklist_item'; + } + Future _replay(PendingOp op) async { switch (op.operation) { case 'create_task_list': @@ -282,14 +314,17 @@ class PendingOpsReplayer { final acknowledgedFields = _request(completedOp).keys; for (final dependent in dependents) { - final baseline = _jsonObject(dependent.baselineRawJson ?? '{}'); + final baseline = _normalizeTaskConflictSnapshot( + _jsonObject(dependent.baselineRawJson ?? '{}'), + ); + final serverSnapshot = _normalizeTaskConflictSnapshot(serverTask.rawJson); final usesWholeTaskConflictBoundary = dependent.operation == 'move_task' || dependent.operation == 'delete_task'; // Keep the original timestamp and untouched fields so a provider edit to // a different field is still detected by a dependent field-level edit. for (final field in acknowledgedFields) { - baseline[field] = serverTask.rawJson[field]; + baseline[field] = serverSnapshot[field]; } await (_database.update( _database.pendingOps, @@ -694,10 +729,8 @@ class PendingOpsReplayer { nextAttemptAtUtc: nextAttempt, lastErrorCode: errorCode, lastErrorMessage: errorMessage, + state: _isCreationOp(op) ? 'retry' : null, ); - if (op.operation == 'create_task') { - await _setPendingOpState(op.id, 'retry'); - } } Future _blockOp( @@ -711,13 +744,45 @@ class PendingOpsReplayer { nextAttemptAtUtc: DateTime.utc(9999, 12, 31), lastErrorCode: errorCode, lastErrorMessage: errorMessage, + state: _isCreationOp(op) ? 'failed' : null, + ); + } + + Future _blockUnknownCreationOutcome( + PendingOp op, + String errorMessage, + ) async { + await _database.pendingOpsDao.updateAttempt( + id: op.id, + attemptCount: op.attemptCount + 1, + nextAttemptAtUtc: DateTime.utc(9999, 12, 31), + lastErrorCode: 'creation_outcome_unknown', + lastErrorMessage: + 'The provider may already have created this item. Automatic replay ' + 'was stopped to avoid a duplicate. $errorMessage', + state: 'recovery_required', + ); + } + + Future _restoreUnknownCreationOutcomeBlock(PendingOp op) { + return _database.pendingOpsDao.updateAttempt( + id: op.id, + attemptCount: op.attemptCount, + nextAttemptAtUtc: DateTime.utc(9999, 12, 31), + lastErrorCode: 'creation_outcome_unknown', + lastErrorMessage: + op.lastErrorMessage ?? + 'The provider may already have created this item. Automatic replay ' + 'was stopped to avoid a duplicate.', + state: 'recovery_required', ); - if (op.operation == 'create_task') { - await _setPendingOpState(op.id, 'failed'); - } } - Future _claimTaskCreate(PendingOp op) async { + bool _hasUnknownCreationOutcome(int statusCode) { + return statusCode == 408 || statusCode >= 500; + } + + Future _claimCreation(PendingOp op) async { final query = _database.update(_database.pendingOps) ..where( (row) => @@ -733,12 +798,6 @@ class PendingOpsReplayer { 1; } - Future _setPendingOpState(String id, String state) { - return (_database.update(_database.pendingOps) - ..where((row) => row.id.equals(id))) - .write(PendingOpsCompanion(state: Value(state))); - } - DateTime _nextAttempt(int attemptCount) { final baseSeconds = min(pow(2, attemptCount).toInt(), 300); final jitterMs = _random.nextInt(max(baseSeconds * 500, 1)); @@ -786,12 +845,16 @@ class PendingOpsReplayer { final baselineJson = op.baselineRawJson == null ? _jsonObject(local.rawJson) : _jsonObject(op.baselineRawJson!); + final normalizedBaseline = _normalizeTaskListConflictSnapshot(baselineJson); + final normalizedCurrent = _normalizeTaskListConflictSnapshot( + current.rawJson, + ); final conflict = const ConflictDetector().detect( entityType: 'task_list', entityId: op.taskListId!, localPendingFields: pendingFields, - lastServerJson: baselineJson, - currentServerJson: current.rawJson, + lastServerJson: normalizedBaseline, + currentServerJson: normalizedCurrent, baselineUpdatedUtc: baselineUpdatedUtc, currentUpdatedUtc: current.updated, ); @@ -834,12 +897,14 @@ class PendingOpsReplayer { final baselineJson = op.baselineRawJson == null ? _jsonObject(local.rawJson) : _jsonObject(op.baselineRawJson!); + final normalizedBaseline = _normalizeTaskConflictSnapshot(baselineJson); + final normalizedCurrent = _normalizeTaskConflictSnapshot(current.rawJson); final conflict = const ConflictDetector().detect( entityType: 'task', entityId: op.taskId!, localPendingFields: pendingFields, - lastServerJson: baselineJson, - currentServerJson: current.rawJson, + lastServerJson: normalizedBaseline, + currentServerJson: normalizedCurrent, baselineUpdatedUtc: baselineUpdatedUtc, currentUpdatedUtc: current.updated, ); @@ -851,6 +916,26 @@ class PendingOpsReplayer { } } + Map _normalizeTaskConflictSnapshot( + Map snapshot, + ) { + final client = _apiClient; + return client is TaskConflictSnapshotNormalizer + ? (client as TaskConflictSnapshotNormalizer) + .normalizeTaskConflictSnapshot(snapshot) + : snapshot; + } + + Map _normalizeTaskListConflictSnapshot( + Map snapshot, + ) { + final client = _apiClient; + return client is TaskConflictSnapshotNormalizer + ? (client as TaskConflictSnapshotNormalizer) + .normalizeTaskListConflictSnapshot(snapshot) + : snapshot; + } + Future _ensureTaskListUnchanged(PendingOp op, String action) async { final baselineUpdatedUtc = _parseUtc(op.baselineUpdatedUtc); if (baselineUpdatedUtc == null || op.taskListId == null) { diff --git a/lib/src/features/tasks/domain/task_remote_client.dart b/lib/src/features/tasks/domain/task_remote_client.dart index 5453ef03..bb1a933c 100644 --- a/lib/src/features/tasks/domain/task_remote_client.dart +++ b/lib/src/features/tasks/domain/task_remote_client.dart @@ -58,6 +58,21 @@ abstract interface class TaskRemoteClient { }); } +/// Adapts provider-native task JSON into the field names used by queued +/// BusyMax mutations before conflict detection compares snapshots. +/// +/// Providers only need to implement this when their resource schema uses +/// different names or representations from [TaskPatch] and [TaskListPatch]. +abstract interface class TaskConflictSnapshotNormalizer { + Map normalizeTaskConflictSnapshot( + Map snapshot, + ); + + Map normalizeTaskListConflictSnapshot( + Map snapshot, + ); +} + /// Child-item boundary used by providers whose subtasks are not task /// resources. Microsoft Graph models Microsoft To Do steps as checklistItem /// children of a todoTask. diff --git a/lib/src/microsoft_todo/api/microsoft_todo_task_remote_client.dart b/lib/src/microsoft_todo/api/microsoft_todo_task_remote_client.dart index 676d5531..9a5d4002 100644 --- a/lib/src/microsoft_todo/api/microsoft_todo_task_remote_client.dart +++ b/lib/src/microsoft_todo/api/microsoft_todo_task_remote_client.dart @@ -6,7 +6,10 @@ import 'microsoft_todo_api_error.dart'; import 'microsoft_todo_api_models.dart'; class MicrosoftTodoTaskRemoteClient - implements TaskRemoteClient, TaskChecklistRemoteClient { + implements + TaskRemoteClient, + TaskChecklistRemoteClient, + TaskConflictSnapshotNormalizer { MicrosoftTodoTaskRemoteClient({ required MicrosoftTodoApiClient client, required String defaultTimeZone, @@ -19,6 +22,69 @@ class MicrosoftTodoTaskRemoteClient final String _defaultTimeZone; final DateTime Function() _nowUtc; + @override + Map normalizeTaskConflictSnapshot( + Map snapshot, + ) { + final normalized = Map.from(snapshot); + if (!normalized.containsKey('notes') && snapshot.containsKey('body')) { + final body = snapshot['body']; + final content = body is Map ? body['content']?.toString() ?? '' : ''; + normalized['notes'] = _htmlToPlainText(content); + } + _putMicrosoftDateTimeAliases( + normalized, + snapshot, + graphField: 'dueDateTime', + dateTimeField: 'microsoftDueDateTime', + timeZoneField: 'microsoftDueTimeZone', + ); + if (!normalized.containsKey('due') && snapshot.containsKey('dueDateTime')) { + final dueDateTime = _dateTimePart(snapshot['dueDateTime']); + normalized['due'] = dueDateTime == null || dueDateTime.length < 10 + ? dueDateTime + : dueDateTime.substring(0, 10); + } + _putMicrosoftDateTimeAliases( + normalized, + snapshot, + graphField: 'startDateTime', + dateTimeField: 'microsoftStartDateTime', + timeZoneField: 'microsoftStartTimeZone', + ); + _putMicrosoftDateTimeAliases( + normalized, + snapshot, + graphField: 'reminderDateTime', + dateTimeField: 'microsoftReminderDateTime', + timeZoneField: 'microsoftReminderTimeZone', + ); + _putMicrosoftDateTimeAliases( + normalized, + snapshot, + graphField: 'completedDateTime', + dateTimeField: 'microsoftCompletedDateTime', + timeZoneField: 'microsoftCompletedTimeZone', + ); + if (!normalized.containsKey('microsoftIsReminderOn') && + snapshot.containsKey('isReminderOn')) { + normalized['microsoftIsReminderOn'] = snapshot['isReminderOn']; + } + return normalized; + } + + @override + Map normalizeTaskListConflictSnapshot( + Map snapshot, + ) { + final normalized = Map.from(snapshot); + if (!normalized.containsKey('title') && + snapshot.containsKey('displayName')) { + normalized['title'] = snapshot['displayName']; + } + return normalized; + } + @override Future createTaskList({required String title}) async { final dto = await _translateMicrosoftErrors( @@ -499,3 +565,29 @@ String _htmlEscape(String text) { .replaceAll('"', '"') .replaceAll("'", '''); } + +void _putMicrosoftDateTimeAliases( + Map normalized, + Map snapshot, { + required String graphField, + required String dateTimeField, + required String timeZoneField, +}) { + if (!snapshot.containsKey(graphField)) return; + final value = snapshot[graphField]; + if (!normalized.containsKey(dateTimeField)) { + normalized[dateTimeField] = value; + } + if (!normalized.containsKey(timeZoneField)) { + normalized[timeZoneField] = value is Map + ? value['timeZone']?.toString() + : null; + } +} + +String? _dateTimePart(Object? value) { + if (value is Map) { + return value['dateTime']?.toString(); + } + return value?.toString(); +} diff --git a/test/features/sync/pending_op_resolution_service_test.dart b/test/features/sync/pending_op_resolution_service_test.dart index 3c011d7e..7e11f3ef 100644 --- a/test/features/sync/pending_op_resolution_service_test.dart +++ b/test/features/sync/pending_op_resolution_service_test.dart @@ -583,6 +583,32 @@ void main() { }, ); + test('unknown-outcome task creation cannot be retried blindly', () async { + await _enqueueBlockedOp( + database, + operation: 'create_task', + taskListId: 'list-1', + taskId: 'local-task-1', + state: 'recovery_required', + ); + + await expectLater( + service.retryNow('op-1'), + throwsA( + isA().having( + (error) => error.message, + 'message', + contains('cannot be retried safely'), + ), + ), + ); + + final pending = await database.pendingOpsDao.getOp('op-1'); + expect(pending!.state, 'recovery_required'); + expect(pending.nextAttemptAtUtc, startsWith('9999-12-31')); + expect(taskSyncCalls, 0); + }); + test( 'retry routes event synchronization through the calendar callback', () async { @@ -721,6 +747,7 @@ Future _enqueueBlockedOp( String? taskListId, String? taskId, String? calendarSourceId, + String state = 'pending', Map request = const {}, }) { return database.pendingOpsDao.enqueue( @@ -734,6 +761,7 @@ Future _enqueueBlockedOp( taskId: Value(taskId), calendarSourceId: Value(calendarSourceId), requestJson: jsonEncode(request), + state: Value(state), nextAttemptAtUtc: const Value('9999-12-31T00:00:00.000Z'), lastErrorCode: const Value('conflict'), createdAtUtc: _now, diff --git a/test/features/sync/pending_ops_replayer_test.dart b/test/features/sync/pending_ops_replayer_test.dart index cc1421e3..92a6fcf4 100644 --- a/test/features/sync/pending_ops_replayer_test.dart +++ b/test/features/sync/pending_ops_replayer_test.dart @@ -406,7 +406,7 @@ void main() { expect(apiClient.calls, ['create_task:list-1']); }); - test('task edit waits while its creation is retrying', () async { + test('task edit waits while a rate-limited creation is retrying', () async { final repository = TasksRepository( database: database, accountId: 'account', @@ -426,8 +426,8 @@ void main() { const TaskPatchInput({'title': 'Edited offline'}), ); apiClient.createTaskError = const GoogleTasksApiError( - statusCode: 503, - message: 'Temporarily unavailable', + statusCode: 429, + message: 'Rate limited', ); final firstApplied = await PendingOpsReplayer( @@ -472,6 +472,106 @@ void main() { expect(operations, isEmpty); }); + test('unknown task creation outcome is not submitted again', () async { + await database.tasksDao.upsertTask( + _task('list-1', 'local-task-1', title: 'Draft'), + ); + await _enqueue( + database, + id: '01', + operation: 'create_task', + taskListId: 'list-1', + taskId: 'local-task-1', + localTempId: 'local-task-1', + request: { + 'body': {'title': 'Draft'}, + }, + ); + apiClient.createTaskError = StateError('response was lost'); + + final replayer = PendingOpsReplayer( + database: database, + apiClient: apiClient, + accountId: 'account', + random: Random(0), + nowUtc: () => DateTime.utc(2026, 6, 4), + ); + + expect(await replayer.replayDueOps(), 0); + final pending = await database.pendingOpsDao.getOp('01'); + expect(pending!.state, 'recovery_required'); + expect(pending.lastErrorCode, 'creation_outcome_unknown'); + expect(pending.nextAttemptAtUtc, startsWith('9999-12-31')); + + await database.pendingOpsDao.retryNow('01', DateTime.utc(2026, 6, 4)); + expect(await replayer.replayDueOps(), 0); + final stillPending = await database.pendingOpsDao.getOp('01'); + expect(stillPending!.state, 'recovery_required'); + expect(stillPending.nextAttemptAtUtc, startsWith('9999-12-31')); + expect(apiClient.calls, ['create_task:list-1']); + }); + + test('abandoned in-progress task creation is not submitted again', () async { + await _enqueue( + database, + id: '01', + operation: 'create_task', + taskListId: 'list-1', + taskId: 'local-task-1', + localTempId: 'local-task-1', + request: { + 'body': {'title': 'Draft'}, + }, + ); + await (database.update(database.pendingOps) + ..where((row) => row.id.equals('01'))) + .write(const PendingOpsCompanion(state: Value('in_progress'))); + + expect( + await PendingOpsReplayer( + database: database, + apiClient: apiClient, + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 6, 4), + ).replayDueOps(), + 0, + ); + + final pending = await database.pendingOpsDao.getOp('01'); + expect(pending!.state, 'recovery_required'); + expect(pending.lastErrorCode, 'creation_outcome_unknown'); + expect(apiClient.calls, isEmpty); + }); + + test('unknown task-list creation outcome is not submitted again', () async { + await database.taskListsDao.upsertTaskList( + _taskList('local-list', title: 'Draft list'), + ); + await _enqueue( + database, + id: '01', + operation: 'create_task_list', + entityType: 'task_list', + taskListId: 'local-list', + localTempId: 'local-list', + request: {'title': 'Draft list'}, + ); + apiClient.createTaskListError = StateError('response was lost'); + + final replayer = PendingOpsReplayer( + database: database, + apiClient: apiClient, + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 6, 4), + ); + expect(await replayer.replayDueOps(), 0); + expect(await replayer.replayDueOps(), 0); + + final pending = await database.pendingOpsDao.getOp('01'); + expect(pending!.state, 'recovery_required'); + expect(apiClient.calls, ['create_task_list:Draft list']); + }); + test('deleting a task whose create is in flight queues after it', () async { final repository = TasksRepository( database: database, @@ -952,6 +1052,111 @@ void main() { expect(op.lastErrorMessage, contains('Remote task changed fields')); }); + for (final conflictCase in [ + ( + name: 'notes', + field: 'notes', + localValue: 'Local notes' as Object?, + baseline: { + 'body': {'content': '

Base notes

', 'contentType': 'html'}, + }, + current: { + 'body': {'content': '

Remote notes

', 'contentType': 'html'}, + }, + ), + ( + name: 'due date', + field: 'microsoftDueDateTime', + localValue: { + 'dateTime': '2026-06-07T09:00:00', + 'timeZone': 'UTC', + }, + baseline: { + 'dueDateTime': {'dateTime': '2026-06-05T09:00:00', 'timeZone': 'UTC'}, + }, + current: { + 'dueDateTime': {'dateTime': '2026-06-06T09:00:00', 'timeZone': 'UTC'}, + }, + ), + ( + name: 'reminder date', + field: 'microsoftReminderDateTime', + localValue: { + 'dateTime': '2026-06-07T08:00:00', + 'timeZone': 'UTC', + }, + baseline: { + 'reminderDateTime': { + 'dateTime': '2026-06-05T08:00:00', + 'timeZone': 'UTC', + }, + }, + current: { + 'reminderDateTime': { + 'dateTime': '2026-06-06T08:00:00', + 'timeZone': 'UTC', + }, + }, + ), + ( + name: 'reminder setting', + field: 'microsoftIsReminderOn', + localValue: true as Object?, + baseline: {'isReminderOn': false}, + current: {'isReminderOn': true}, + ), + ]) { + test( + 'Microsoft ${conflictCase.name} overlap is blocked before mutation', + () async { + final baselineRaw = { + 'id': 'task-1', + 'title': 'Task', + 'lastModifiedDateTime': '2026-06-04T00:00:00.000Z', + ...conflictCase.baseline, + }; + final remoteRaw = { + 'id': 'task-1', + 'title': 'Task', + 'lastModifiedDateTime': '2026-06-04T00:10:00.000Z', + ...conflictCase.current, + }; + await database.tasksDao.upsertTask( + _task( + 'list-1', + 'task-1', + updatedUtc: '2026-06-04T00:00:00.000Z', + rawJson: jsonEncode(baselineRaw), + ), + ); + await _enqueue( + database, + id: '01', + operation: 'patch_task', + taskListId: 'list-1', + taskId: 'task-1', + request: {conflictCase.field: conflictCase.localValue}, + baselineUpdatedUtc: '2026-06-04T00:00:00.000Z', + baselineRawJson: jsonEncode(baselineRaw), + ); + final graphClient = _ConflictMicrosoftTodoApiClient(remoteRaw); + + final applied = await PendingOpsReplayer( + database: database, + apiClient: _microsoftAdapter(graphClient), + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 6, 4, 1), + ).replayDueOps(); + + final pending = await database.pendingOpsDao.getOp('01'); + expect(applied, 0); + expect(graphClient.updateTaskCalls, 0); + expect(pending!.lastErrorCode, 'conflict'); + expect(pending.lastErrorMessage, contains(conflictCase.field)); + }, + ); + } + test( 'back-to-back local task patches replay in order without self-conflict', () async { @@ -1649,6 +1854,37 @@ void main() { expect(await database.select(database.pendingOps).get(), isEmpty); }, ); + + test('unknown checklist creation outcome is not submitted again', () async { + final checklistClient = _ChecklistTaskRemoteClient() + ..createChecklistItemError = StateError('response was lost'); + await _enqueue( + database, + id: '01', + operation: 'create_task_checklist_item', + entityType: 'task_checklist_item', + taskListId: 'list-1', + taskId: 'task-1', + localTempId: 'local-step', + request: { + 'checklistItemId': 'local-step', + 'body': {'displayName': 'Step', 'isChecked': false}, + }, + ); + + final replayer = PendingOpsReplayer( + database: database, + apiClient: checklistClient, + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 6, 4), + ); + expect(await replayer.replayDueOps(), 0); + expect(await replayer.replayDueOps(), 0); + + final pending = await database.pendingOpsDao.getOp('01'); + expect(pending!.state, 'recovery_required'); + expect(checklistClient.checklistCalls, ['create:Step']); + }); } MicrosoftTodoTaskRemoteClient _microsoftAdapter(MicrosoftTodoApiClient client) { @@ -1698,6 +1934,33 @@ class _ThrowingMicrosoftTodoApiClient implements MicrosoftTodoApiClient { dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); } +class _ConflictMicrosoftTodoApiClient implements MicrosoftTodoApiClient { + _ConflictMicrosoftTodoApiClient(Map remoteTask) + : remoteTask = MicrosoftTodoTaskDto.fromJson(remoteTask); + + final MicrosoftTodoTaskDto remoteTask; + int updateTaskCalls = 0; + + @override + Future getTask({ + required String taskListId, + required String taskId, + }) async => remoteTask; + + @override + Future updateTask({ + required String taskListId, + required String taskId, + required Map patch, + }) async { + updateTaskCalls += 1; + return MicrosoftTodoTaskDto.fromJson({...remoteTask.rawJson, ...patch}); + } + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + class _FakeTaskRemoteClient implements TaskRemoteClient { TaskDto? createdTask; final calls = []; @@ -1705,7 +1968,8 @@ class _FakeTaskRemoteClient implements TaskRemoteClient { final createParentTaskIds = []; final moveParentTaskIds = []; GoogleTasksApiError? patchTaskListError; - GoogleTasksApiError? createTaskError; + Object? createTaskListError; + Object? createTaskError; GoogleTasksApiError? deleteTaskError; GoogleTasksApiError? clearCompletedError; GoogleTasksApiError? moveTaskError; @@ -1719,6 +1983,8 @@ class _FakeTaskRemoteClient implements TaskRemoteClient { @override Future createTaskList({required String title}) async { calls.add('create_task_list:$title'); + final error = createTaskListError; + if (error != null) throw error; return _taskListDto('list-server', title: title); } @@ -1882,6 +2148,7 @@ class _FakeTaskRemoteClient implements TaskRemoteClient { class _ChecklistTaskRemoteClient extends _FakeTaskRemoteClient implements TaskChecklistRemoteClient { final checklistCalls = []; + Object? createChecklistItemError; @override Future createChecklistItem({ @@ -1891,6 +2158,8 @@ class _ChecklistTaskRemoteClient extends _FakeTaskRemoteClient bool completed = false, }) async { checklistCalls.add('create:$title'); + final error = createChecklistItemError; + if (error != null) throw error; return TaskChecklistItemDto( id: 'server-step', title: title, From d057acf68ae4b33fe947508334b32990c686c1b5 Mon Sep 17 00:00:00 2001 From: albert Date: Fri, 18 Sep 2026 17:41:41 -0700 Subject: [PATCH 17/25] Fix Linux native weekday reader, open search-filter interaction --- .github/workflows/flutter-linux.yml | 8 +- lib/src/android/android_app.dart | 6 +- .../presentation/android_schedule_screen.dart | 64 +++++---- lib/src/app/busymax_app.dart | 15 +- lib/src/app/windows/windows_busymax_app.dart | 18 ++- .../presentation/schedule_workspace.dart | 12 +- lib/src/l10n/week_preferences_scope.dart | 56 +++++++- lib/src/ui/windows/windows_schedule_page.dart | 84 ++++++----- linux/runner/first_weekday.h | 40 ++++++ linux/runner/my_application.cc | 32 +---- linux/runner/tests/first_weekday_test.cc | 40 ++++++ test/app/first_day_of_week_test.dart | 132 ++++++++++++++++++ .../schedule_search_platform_test.dart | 120 ++++++++++++++-- 13 files changed, 505 insertions(+), 122 deletions(-) create mode 100644 linux/runner/first_weekday.h create mode 100644 linux/runner/tests/first_weekday_test.cc diff --git a/.github/workflows/flutter-linux.yml b/.github/workflows/flutter-linux.yml index 37bd5aff..2d24dd0e 100644 --- a/.github/workflows/flutter-linux.yml +++ b/.github/workflows/flutter-linux.yml @@ -32,7 +32,9 @@ jobs: libsecret-1-dev \ libjsoncpp-dev \ liblzma-dev \ + locales \ xvfb + sudo locale-gen en_US.UTF-8 en_GB.UTF-8 - name: Set up Flutter uses: subosito/flutter-action@v2 @@ -87,13 +89,17 @@ jobs: - name: Test run: '"$BUSYMAX_FLUTTER_EXECUTABLE" test' - - name: Test native clock patterns and optional GTK time picker + - name: Test native clock patterns, weekday locale, and GTK time picker run: | mkdir -p build/linux/native-tests g++ -std=c++17 -Wall -Wextra -Werror \ windows/runner/tests/short_time_pattern_test.cpp \ -o build/linux/native-tests/short_time_pattern_test build/linux/native-tests/short_time_pattern_test + g++ -std=c++17 -Wall -Wextra -Werror \ + linux/runner/tests/first_weekday_test.cc \ + -o build/linux/native-tests/first_weekday_test + build/linux/native-tests/first_weekday_test g++ -std=c++17 -Wall -Wextra -Werror \ linux/runner/tests/time_picker_test.cc \ -o build/linux/native-tests/time_picker_test \ diff --git a/lib/src/android/android_app.dart b/lib/src/android/android_app.dart index d10ad85c..a858a28e 100644 --- a/lib/src/android/android_app.dart +++ b/lib/src/android/android_app.dart @@ -84,7 +84,11 @@ class _AndroidBusyMaxAppState extends ConsumerState { systemWeekday: _firstWeekdayController.value, platformLocaleTag: WidgetsBinding.instance.platformDispatcher.locale .toLanguageTag(), - child: child ?? const SizedBox.shrink(), + child: BusyMaxWeekPreferencesStartupGate( + preference: settings.firstDayOfWeekPreference, + systemValueInitialized: _firstWeekdayController.isInitialized, + child: child ?? const SizedBox.shrink(), + ), ), ), home: const AndroidHomeShell(), diff --git a/lib/src/android/presentation/android_schedule_screen.dart b/lib/src/android/presentation/android_schedule_screen.dart index 14d2d0f2..8cba5a2a 100644 --- a/lib/src/android/presentation/android_schedule_screen.dart +++ b/lib/src/android/presentation/android_schedule_screen.dart @@ -328,34 +328,46 @@ class _AndroidScheduleScreenState extends ConsumerState { showDragHandle: true, isScrollControlled: true, builder: (context) => StatefulBuilder( - builder: (context, update) => SafeArea( - child: Padding( - padding: EdgeInsetsDirectional.only( - bottom: MediaQuery.viewInsetsOf(context).bottom, - ), - child: SizedBox( - height: MediaQuery.sizeOf(context).height * .8, - child: AndroidScheduleSearchFilters( - value: _searchCriteria!, - accounts: - ref.read(accountsStreamProvider).valueOrNull ?? const [], - sources: - ref.read(calendarSourcesStreamProvider).valueOrNull ?? - const [], - taskLists: - ref.read(scheduleTaskListsProvider).valueOrNull ?? const [], - onChanged: (value) { - setState(() => _searchCriteria = value); - update(() {}); - }, - onClear: () { - setState(() => _searchCriteria = _initialSearchCriteria); - update(() {}); - }, + builder: (context, update) { + final firstWeekday = _firstWeekday(context); + return SafeArea( + child: Padding( + padding: EdgeInsetsDirectional.only( + bottom: MediaQuery.viewInsetsOf(context).bottom, + ), + child: SizedBox( + height: MediaQuery.sizeOf(context).height * .8, + child: AndroidScheduleSearchFilters( + value: _searchCriteria!.copyWith(firstWeekday: firstWeekday), + accounts: + ref.read(accountsStreamProvider).valueOrNull ?? const [], + sources: + ref.read(calendarSourcesStreamProvider).valueOrNull ?? + const [], + taskLists: + ref.read(scheduleTaskListsProvider).valueOrNull ?? + const [], + onChanged: (value) { + setState( + () => _searchCriteria = value.copyWith( + firstWeekday: _firstWeekday(context), + ), + ); + update(() {}); + }, + onClear: () { + setState( + () => _searchCriteria = _initialSearchCriteria?.copyWith( + firstWeekday: _firstWeekday(context), + ), + ); + update(() {}); + }, + ), ), ), - ), - ), + ); + }, ), ); } diff --git a/lib/src/app/busymax_app.dart b/lib/src/app/busymax_app.dart index 4cf3d9ad..80ca6a88 100644 --- a/lib/src/app/busymax_app.dart +++ b/lib/src/app/busymax_app.dart @@ -356,11 +356,16 @@ class _BusyMaxAppState extends ConsumerState { .platformDispatcher .locale .toLanguageTag(), - child: MediaQuery( - data: MediaQuery.of( - context, - ).copyWith(alwaysUse24HourFormat: clock.use24Hour), - child: child ?? const SizedBox.shrink(), + child: BusyMaxWeekPreferencesStartupGate( + preference: settings.firstDayOfWeekPreference, + systemValueInitialized: + _firstWeekdayController.isInitialized, + child: MediaQuery( + data: MediaQuery.of( + context, + ).copyWith(alwaysUse24HourFormat: clock.use24Hour), + child: child ?? const SizedBox.shrink(), + ), ), ), ), diff --git a/lib/src/app/windows/windows_busymax_app.dart b/lib/src/app/windows/windows_busymax_app.dart index 33b07f81..5ac21c29 100644 --- a/lib/src/app/windows/windows_busymax_app.dart +++ b/lib/src/app/windows/windows_busymax_app.dart @@ -247,13 +247,17 @@ class _WindowsBusyMaxAppState extends ConsumerState systemWeekday: _firstWeekdayController.value, platformLocaleTag: WidgetsBinding.instance.platformDispatcher.locale .toLanguageTag(), - child: MediaQuery( - data: MediaQuery.of( - context, - ).copyWith(alwaysUse24HourFormat: clock.use24Hour), - child: WindowsDesktopRuntime( - startMinimizedAtLaunch: widget.startMinimizedAtLaunch, - child: child ?? const SizedBox.shrink(), + child: BusyMaxWeekPreferencesStartupGate( + preference: settings.firstDayOfWeekPreference, + systemValueInitialized: _firstWeekdayController.isInitialized, + child: MediaQuery( + data: MediaQuery.of( + context, + ).copyWith(alwaysUse24HourFormat: clock.use24Hour), + child: WindowsDesktopRuntime( + startMinimizedAtLaunch: widget.startMinimizedAtLaunch, + child: child ?? const SizedBox.shrink(), + ), ), ), ), diff --git a/lib/src/features/schedule/presentation/schedule_workspace.dart b/lib/src/features/schedule/presentation/schedule_workspace.dart index 7e70dfa7..6d446474 100644 --- a/lib/src/features/schedule/presentation/schedule_workspace.dart +++ b/lib/src/features/schedule/presentation/schedule_workspace.dart @@ -1069,11 +1069,19 @@ class _ScheduleWorkspaceState extends ConsumerState { taskLists: _searchTaskLists, sidebar: sidebar, onChanged: (value) { - setState(() => _searchCriteria = value); + setState( + () => _searchCriteria = value.copyWith( + firstWeekday: _firstWeekday(context), + ), + ); refresh?.call(); }, onClear: () { - setState(() => _searchCriteria = _initialSearchCriteria); + setState( + () => _searchCriteria = _initialSearchCriteria?.copyWith( + firstWeekday: _firstWeekday(context), + ), + ); refresh?.call(); }, ), diff --git a/lib/src/l10n/week_preferences_scope.dart b/lib/src/l10n/week_preferences_scope.dart index 0ca7a94a..f157df1e 100644 --- a/lib/src/l10n/week_preferences_scope.dart +++ b/lib/src/l10n/week_preferences_scope.dart @@ -31,7 +31,10 @@ abstract base class BusyMaxSystemFirstWeekdaySourceBase /// Application-lifetime owner of native refreshes and lifecycle observation. class BusyMaxSystemFirstWeekdayController extends ValueNotifier with WidgetsBindingObserver { - BusyMaxSystemFirstWeekdayController(this._source) : super(null) { + BusyMaxSystemFirstWeekdayController( + this._source, { + this.readTimeout = const Duration(seconds: 2), + }) : super(null) { WidgetsBinding.instance.addObserver(this); _subscription = _source.changes.listen( (_) => unawaited(refresh()), @@ -41,20 +44,35 @@ class BusyMaxSystemFirstWeekdayController extends ValueNotifier } final BusyMaxSystemFirstWeekdaySource _source; + final Duration readTimeout; + final _ready = Completer(); StreamSubscription? _subscription; var _refreshSequence = 0; var _disposed = false; + var _initialized = false; + + bool get isInitialized => _initialized; + + Future get ready => _ready.future; Future refresh() async { final sequence = ++_refreshSequence; int? result; try { - result = await _source.read(); + result = await _source.read().timeout(readTimeout); } on Object { result = null; } if (_disposed || sequence != _refreshSequence) return; - value = isValidBusyMaxWeekday(result) ? result : null; + final next = isValidBusyMaxWeekday(result) ? result : null; + final firstResult = !_initialized; + _initialized = true; + if (value != next) { + value = next; + } else if (firstResult) { + notifyListeners(); + } + if (!_ready.isCompleted) _ready.complete(); } @override @@ -69,6 +87,7 @@ class BusyMaxSystemFirstWeekdayController extends ValueNotifier void dispose() { _disposed = true; _refreshSequence++; + if (!_ready.isCompleted) _ready.complete(); WidgetsBinding.instance.removeObserver(this); unawaited(_subscription?.cancel()); _source.dispose(); @@ -76,6 +95,28 @@ class BusyMaxSystemFirstWeekdayController extends ValueNotifier } } +/// Prevents an automatic calendar from rendering while its initial native +/// preference is unresolved. Manual preferences never wait for that read. +class BusyMaxWeekPreferencesStartupGate extends StatelessWidget { + const BusyMaxWeekPreferencesStartupGate({ + required this.preference, + required this.systemValueInitialized, + required this.child, + super.key, + }); + + final BusyMaxFirstDayOfWeekPreference preference; + final bool systemValueInitialized; + final Widget child; + + @override + Widget build(BuildContext context) => + preference != BusyMaxFirstDayOfWeekPreference.system || + systemValueInitialized + ? child + : const SizedBox.shrink(); +} + /// Place above the navigator so routes and overlays share one effective value. class BusyMaxWeekPreferencesScope extends InheritedWidget { const BusyMaxWeekPreferencesScope({ @@ -124,8 +165,13 @@ String busyMaxFirstDayOfWeekPreferenceLabel( BuildContext context, BusyMaxFirstDayOfWeekPreference preference, ) { - final effective = - preference.weekday ?? BusyMaxWeekPreferencesScope.firstWeekdayOf(context); + final scope = BusyMaxWeekPreferencesScope.of(context); + final automatic = resolveBusyMaxFirstWeekday( + preference: BusyMaxFirstDayOfWeekPreference.system, + systemWeekday: scope.systemWeekday, + platformLocaleTag: scope.platformLocaleTag, + ); + final effective = preference.weekday ?? automatic; final locale = Localizations.localeOf(context).toLanguageTag(); final weekday = localizedWeekdayLabel( locale, diff --git a/lib/src/ui/windows/windows_schedule_page.dart b/lib/src/ui/windows/windows_schedule_page.dart index 220d5f2e..0a2d4c99 100644 --- a/lib/src/ui/windows/windows_schedule_page.dart +++ b/lib/src/ui/windows/windows_schedule_page.dart @@ -765,20 +765,32 @@ class _WindowsSchedulePageState extends ConsumerState { List sources, List taskLists, { VoidCallback? refresh, - }) => WindowsScheduleSearchPane( - value: _searchCriteria!, - accounts: accounts, - sources: sources, - taskLists: taskLists, - onChanged: (value) { - setState(() => _searchCriteria = value); - refresh?.call(); - }, - onClear: () { - setState(() => _searchCriteria = _initialSearchCriteria); - refresh?.call(); - }, - ); + int? firstWeekday, + }) { + final currentFirstWeekday = firstWeekday ?? _firstWeekday; + return WindowsScheduleSearchPane( + value: _searchCriteria!.copyWith(firstWeekday: currentFirstWeekday), + accounts: accounts, + sources: sources, + taskLists: taskLists, + onChanged: (value) { + setState( + () => _searchCriteria = value.copyWith( + firstWeekday: currentFirstWeekday, + ), + ); + refresh?.call(); + }, + onClear: () { + setState( + () => _searchCriteria = _initialSearchCriteria?.copyWith( + firstWeekday: currentFirstWeekday, + ), + ); + refresh?.call(); + }, + ); + } void _dismissSearch() { if (!_searchActive) return; @@ -1041,25 +1053,31 @@ class _WindowsSchedulePageState extends ConsumerState { return showDialog( context: context, builder: (context) => StatefulBuilder( - builder: (context, update) => ContentDialog( - title: Text(AppLocalizations.of(context).searchFilters), - content: SizedBox( - width: 360, - height: math.min(MediaQuery.sizeOf(context).height - 180, 620), - child: _searchPane( - accounts, - sources, - taskLists, - refresh: () => update(() {}), - ), - ), - actions: [ - Button( - onPressed: () => Navigator.pop(context), - child: Text(AppLocalizations.of(context).close), + builder: (context, update) { + final firstWeekday = BusyMaxWeekPreferencesScope.firstWeekdayOf( + context, + ); + return ContentDialog( + title: Text(AppLocalizations.of(context).searchFilters), + content: SizedBox( + width: 360, + height: math.min(MediaQuery.sizeOf(context).height - 180, 620), + child: _searchPane( + accounts, + sources, + taskLists, + firstWeekday: firstWeekday, + refresh: () => update(() {}), + ), ), - ], - ), + actions: [ + Button( + onPressed: () => Navigator.pop(context), + child: Text(AppLocalizations.of(context).close), + ), + ], + ); + }, ), ); } @@ -1074,7 +1092,7 @@ class _WindowsSchedulePageState extends ConsumerState { width: 520, height: math.min(MediaQuery.sizeOf(context).height - 180, 620), child: WindowsScheduleSourcePane( - firstWeekday: _firstWeekday, + firstWeekday: BusyMaxWeekPreferencesScope.firstWeekdayOf(context), selectedDate: _selectedDate, accounts: accounts, calendarSources: sources, diff --git a/linux/runner/first_weekday.h b/linux/runner/first_weekday.h new file mode 100644 index 00000000..123329d4 --- /dev/null +++ b/linux/runner/first_weekday.h @@ -0,0 +1,40 @@ +#ifndef BUSYMAX_FIRST_WEEKDAY_H_ +#define BUSYMAX_FIRST_WEEKDAY_H_ + +#include + +#include +#include + +// Reads the effective LC_TIME convention using glibc's private locale items. +// +// _NL_TIME_FIRST_WEEKDAY is a one-byte offset from the date encoded by +// _NL_TIME_WEEK_1STDAY. For the numeric item, glibc returns the word in the +// pointer value itself; the returned address must not be dereferenced. +inline std::optional BusyMaxReadFirstWeekday() { +#if defined(__GLIBC__) && defined(__USE_GNU) + const char* relative_bytes = nl_langinfo(_NL_TIME_FIRST_WEEKDAY); + if (relative_bytes == nullptr) return std::nullopt; + + const unsigned relative_weekday = + static_cast(relative_bytes[0]); + if (relative_weekday < 1 || relative_weekday > 7) return std::nullopt; + + const std::uintptr_t origin_word = reinterpret_cast( + nl_langinfo(_NL_TIME_WEEK_1STDAY)); + int origin_weekday = 0; + if (origin_word == 19971130U) { + origin_weekday = 7; // Sunday in Dart numbering. + } else if (origin_word == 19971201U) { + origin_weekday = 1; // Monday in Dart numbering. + } else { + return std::nullopt; + } + + return ((origin_weekday + static_cast(relative_weekday) - 2) % 7) + 1; +#else + return std::nullopt; +#endif +} + +#endif // BUSYMAX_FIRST_WEEKDAY_H_ diff --git a/linux/runner/my_application.cc b/linux/runner/my_application.cc index fe11cc97..952ef369 100644 --- a/linux/runner/my_application.cc +++ b/linux/runner/my_application.cc @@ -1,5 +1,6 @@ #include "time_picker.h" #include "my_application.h" +#include "first_weekday.h" #include #include @@ -4596,35 +4597,10 @@ static void gtk_settings_method_call_cb(FlMethodChannel* channel, g_autoptr(FlValue) result = get_gtk_font_settings(); fl_method_call_respond_success(method_call, result, nullptr); } else if (strcmp(method, "getFirstWeekday") == 0) { -#if defined(_NL_TIME_FIRST_WEEKDAY) && defined(_NL_TIME_WEEK_1STDAY) - const char* weekday_bytes = nl_langinfo(_NL_TIME_FIRST_WEEKDAY); - const char* origin_bytes = nl_langinfo(_NL_TIME_WEEK_1STDAY); - if (weekday_bytes == nullptr || origin_bytes == nullptr) { - fl_method_call_respond_success(method_call, nullptr, nullptr); - return; - } - const guint relative_weekday = - static_cast(weekday_bytes[0]); - std::uint32_t origin = 0; - std::memcpy(&origin, origin_bytes, sizeof(origin)); - gint origin_weekday = 0; - if (origin == 19971130) { - origin_weekday = 7; // Sunday in Dart numbering. - } else if (origin == 19971201) { - origin_weekday = 1; // Monday in Dart numbering. - } - if (origin_weekday == 0 || relative_weekday < 1 || - relative_weekday > 7) { - fl_method_call_respond_success(method_call, nullptr, nullptr); - return; - } - const gint dart_weekday = - ((origin_weekday + static_cast(relative_weekday) - 2) % 7) + 1; - g_autoptr(FlValue) result = fl_value_new_int(dart_weekday); + const auto weekday = BusyMaxReadFirstWeekday(); + g_autoptr(FlValue) result = + weekday ? fl_value_new_int(*weekday) : nullptr; fl_method_call_respond_success(method_call, result, nullptr); -#else - fl_method_call_respond_success(method_call, nullptr, nullptr); -#endif } else if (strcmp(method, "getGtkThemeColors") == 0) { g_autoptr(FlValue) result = get_gtk_theme_colors(); fl_method_call_respond_success(method_call, result, nullptr); diff --git a/linux/runner/tests/first_weekday_test.cc b/linux/runner/tests/first_weekday_test.cc new file mode 100644 index 00000000..7e75a7a2 --- /dev/null +++ b/linux/runner/tests/first_weekday_test.cc @@ -0,0 +1,40 @@ +#include "../first_weekday.h" + +#include +#include +#include +#include +#include + +namespace { + +void RequireLocale(const char* category_name, int category, + const char* locale_name) { + if (std::setlocale(category, locale_name) == nullptr) { + std::fprintf(stderr, "Required %s locale is unavailable: %s\n", + category_name, locale_name); + std::abort(); + } +} + +} // namespace + +int main() { + // Execute the real glibc reader in the process's effective locale. This + // catches both accidental preprocessing-out and invalid pointer decoding. + assert(std::setlocale(LC_ALL, "") != nullptr); + const auto effective = BusyMaxReadFirstWeekday(); + assert(effective.has_value()); + assert(*effective >= 1 && *effective <= 7); + + // The display language and time-format locale are independent. Keep + // messages in US English while selecting the British LC_TIME convention. + RequireLocale("LC_MESSAGES", LC_MESSAGES, "en_US.UTF-8"); + RequireLocale("LC_TIME", LC_TIME, "en_GB.UTF-8"); + assert(std::string(std::setlocale(LC_MESSAGES, nullptr)).find("en_US") == 0); + assert(BusyMaxReadFirstWeekday() == 1); // Monday. + + RequireLocale("LC_TIME", LC_TIME, "en_US.UTF-8"); + assert(BusyMaxReadFirstWeekday() == 7); // Sunday. + return 0; +} diff --git a/test/app/first_day_of_week_test.dart b/test/app/first_day_of_week_test.dart index 9e96ea2b..43e3ed99 100644 --- a/test/app/first_day_of_week_test.dart +++ b/test/app/first_day_of_week_test.dart @@ -131,6 +131,138 @@ void main() { expect(find.text('Systemstandard (Sonntag)'), findsOneWidget); }); + + testWidgets( + 'manual calendar stays fixed while the automatic option tracks the system', + (tester) async { + final systemWeekday = ValueNotifier(DateTime.sunday); + addTearDown(systemWeekday.dispose); + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: const [ + AppLocalizations.delegate, + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + ], + supportedLocales: const [Locale('en')], + builder: (context, child) => ValueListenableBuilder( + valueListenable: systemWeekday, + builder: (context, value, child) => BusyMaxWeekPreferencesScope( + preference: BusyMaxFirstDayOfWeekPreference.wednesday, + systemWeekday: value, + platformLocaleTag: 'en-US', + child: child!, + ), + child: child, + ), + home: Builder( + builder: (context) => Column( + children: [ + Text( + 'effective: ' + '${BusyMaxWeekPreferencesScope.firstWeekdayOf(context)}', + ), + Text( + busyMaxFirstDayOfWeekPreferenceLabel( + context, + BusyMaxFirstDayOfWeekPreference.system, + ), + ), + ], + ), + ), + ), + ); + + expect(find.text('effective: 3'), findsOneWidget); + expect(find.text('System default (Sunday)'), findsOneWidget); + + systemWeekday.value = DateTime.friday; + await tester.pump(); + + expect(find.text('effective: 3'), findsOneWidget); + expect(find.text('System default (Friday)'), findsOneWidget); + }, + ); + + testWidgets('automatic calendar waits for a delayed initial native result', ( + tester, + ) async { + final source = _ControlledSource(); + final controller = BusyMaxSystemFirstWeekdayController(source); + addTearDown(controller.dispose); + + await tester.pumpWidget( + ValueListenableBuilder( + valueListenable: controller, + builder: (context, value, _) => BusyMaxWeekPreferencesScope( + preference: BusyMaxFirstDayOfWeekPreference.system, + systemWeekday: value, + platformLocaleTag: 'en-US', + child: BusyMaxWeekPreferencesStartupGate( + preference: BusyMaxFirstDayOfWeekPreference.system, + systemValueInitialized: controller.isInitialized, + child: MaterialApp( + home: Builder( + builder: (context) => Text( + 'calendar: ' + '${BusyMaxWeekPreferencesScope.firstWeekdayOf(context)}', + ), + ), + ), + ), + ), + ), + ); + + expect(controller.isInitialized, isFalse); + expect(find.textContaining('calendar:'), findsNothing); + + source.reads.single.complete(DateTime.saturday); + await tester.pump(); + + expect(controller.isInitialized, isTrue); + expect(find.text('calendar: 6'), findsOneWidget); + }); + + testWidgets('manual calendar does not wait for native initialization', ( + tester, + ) async { + await tester.pumpWidget( + const BusyMaxWeekPreferencesStartupGate( + preference: BusyMaxFirstDayOfWeekPreference.thursday, + systemValueInitialized: false, + child: Text('manual calendar', textDirection: TextDirection.ltr), + ), + ); + + expect(find.text('manual calendar'), findsOneWidget); + }); + + testWidgets('a stalled native read reaches locale fallback after timeout', ( + tester, + ) async { + final source = _ControlledSource(); + final controller = BusyMaxSystemFirstWeekdayController( + source, + readTimeout: const Duration(milliseconds: 10), + ); + addTearDown(controller.dispose); + + await tester.pump(const Duration(milliseconds: 11)); + + expect(controller.isInitialized, isTrue); + expect(controller.value, isNull); + expect( + resolveBusyMaxFirstWeekday( + preference: BusyMaxFirstDayOfWeekPreference.system, + systemWeekday: controller.value, + platformLocaleTag: 'en-US', + ), + DateTime.sunday, + ); + }); } class _ControlledSource implements BusyMaxSystemFirstWeekdaySource { diff --git a/test/features/schedule/presentation/schedule_search_platform_test.dart b/test/features/schedule/presentation/schedule_search_platform_test.dart index 307fe9eb..d82021d9 100644 --- a/test/features/schedule/presentation/schedule_search_platform_test.dart +++ b/test/features/schedule/presentation/schedule_search_platform_test.dart @@ -13,6 +13,7 @@ import 'package:busymax/src/features/schedule/presentation/schedule_sidebar.dart import 'package:busymax/src/features/schedule/presentation/schedule_workspace.dart'; import 'package:busymax/src/features/task_lists/data/task_lists_repository.dart'; import 'package:busymax/src/features/tasks/data/tasks_repository.dart'; +import 'package:busymax/src/l10n/week_preferences_scope.dart'; import 'package:busymax/src/platform/linux_header_bar_provider.dart'; import 'package:busymax/src/platform/linux_header_bar_service.dart'; import 'package:busymax/src/providers/busy_provider.dart'; @@ -405,6 +406,86 @@ void main() { expect(tester.takeException(), isNull); }, ); + + testWidgets( + 'Android confirms an open date filter with the latest system weekday', + (tester) async { + final systemWeekday = ValueNotifier(DateTime.monday); + addTearDown(systemWeekday.dispose); + await _mount(tester, 'android', width: 480, systemWeekday: systemWeekday); + await tester.pumpAndSettle(); + await tester.tap(find.byIcon(Icons.search)); + await tester.pumpAndSettle(); + await tester.tap(find.byIcon(Icons.tune)); + await tester.pumpAndSettle(); + + final panel = find.byType(AndroidScheduleSearchFilters); + var filters = tester.widget(panel); + filters.onChanged( + filters.value.copyWith( + date: ScheduleSearchDate.custom, + customStart: filters.value.referenceDate, + customEnd: filters.value.referenceDate, + ), + ); + await tester.pumpAndSettle(); + + final startDate = find + .descendant(of: panel, matching: find.byType(OutlinedButton)) + .first; + await tester.ensureVisible(startDate); + await tester.tap(startDate); + await tester.pumpAndSettle(); + expect(find.byType(DatePickerDialog), findsOneWidget); + + systemWeekday.value = DateTime.sunday; + await tester.pump(); + await tester.tap(find.text('OK').last); + await tester.pumpAndSettle(); + + filters = tester.widget(panel); + expect(filters.value.firstWeekday, DateTime.sunday); + filters.onChanged( + filters.value.copyWith(date: ScheduleSearchDate.thisWeek), + ); + await tester.pumpAndSettle(); + filters = tester.widget(panel); + expect(filters.value.range?.start.weekday, DateTime.sunday); + + filters.onClear(); + await tester.pumpAndSettle(); + expect( + tester.widget(panel).value.firstWeekday, + DateTime.sunday, + ); + }, + ); + + testWidgets('Windows compact mini-calendar tracks an open system change', ( + tester, + ) async { + final systemWeekday = ValueNotifier(DateTime.monday); + addTearDown(systemWeekday.dispose); + await _mount(tester, 'windows', width: 650, systemWeekday: systemWeekday); + await tester.pumpAndSettle(); + + await tester.sendKeyEvent(LogicalKeyboardKey.f9); + await tester.pumpAndSettle(); + final pane = find.byType(WindowsScheduleSourcePane); + expect(pane, findsOneWidget); + expect( + tester.widget(pane).firstWeekday, + DateTime.monday, + ); + + systemWeekday.value = DateTime.sunday; + await tester.pump(); + + expect( + tester.widget(pane).firstWeekday, + DateTime.sunday, + ); + }); } Future _searchShortcut(WidgetTester tester) async { @@ -418,6 +499,7 @@ Future<({AppDatabase db, ProviderContainer container})> _mount( WidgetTester tester, String platform, { double width = 1280, + ValueListenable? systemWeekday, }) async { tester.view ..physicalSize = Size(width, 900) @@ -530,21 +612,31 @@ Future<({AppDatabase db, ProviderContainer container})> _mount( header.dispose(); await db.close(); }); + final platformApp = platform == 'windows' + ? fluent.FluentApp( + localizationsDelegates: const [AppLocalizations.delegate], + supportedLocales: AppLocalizations.supportedLocales, + home: const WindowsSchedulePage(), + ) + : localizedTestApp( + child: platform == 'linux' + ? const ScheduleWorkspace() + : const AndroidScheduleScreen(), + ); + final app = systemWeekday == null + ? platformApp + : ValueListenableBuilder( + valueListenable: systemWeekday, + child: platformApp, + builder: (context, value, child) => BusyMaxWeekPreferencesScope( + preference: BusyMaxFirstDayOfWeekPreference.system, + systemWeekday: value, + platformLocaleTag: 'en-GB', + child: child!, + ), + ); await tester.pumpWidget( - UncontrolledProviderScope( - container: container, - child: platform == 'windows' - ? fluent.FluentApp( - localizationsDelegates: const [AppLocalizations.delegate], - supportedLocales: AppLocalizations.supportedLocales, - home: const WindowsSchedulePage(), - ) - : localizedTestApp( - child: platform == 'linux' - ? const ScheduleWorkspace() - : const AndroidScheduleScreen(), - ), - ), + UncontrolledProviderScope(container: container, child: app), ); return (db: db, container: container); } From fe9253badd3e27ba498848318ad6e29c08674811 Mon Sep 17 00:00:00 2001 From: albert Date: Fri, 18 Sep 2026 19:38:08 -0700 Subject: [PATCH 18/25] Refactor `BusyMaxReadFirstWeekday` implementation for clarity and GLIBC decoding consistency on Linux; add comprehensive unit tests for synthetic origins and edge cases, and introduce weekday startup activation tests. --- .github/workflows/flutter-linux.yml | 8 + .../presentation/android_date_picker.dart | 98 ++- lib/src/app/busymax_app.dart | 57 +- .../desktop_calendar_open_readiness.dart | 58 ++ lib/src/app/windows/windows_busymax_app.dart | 107 ++- linux/runner/first_weekday.h | 36 +- linux/runner/tests/first_weekday_test.cc | 41 ++ test/app/first_day_of_week_test.dart | 1 + test/app/weekday_startup_activation_test.dart | 655 ++++++++++++++++++ .../schedule_search_platform_test.dart | 1 + test/flutter_test_config.dart | 21 + 11 files changed, 1014 insertions(+), 69 deletions(-) create mode 100644 lib/src/app/common/desktop_calendar_open_readiness.dart create mode 100644 test/app/weekday_startup_activation_test.dart create mode 100644 test/flutter_test_config.dart diff --git a/.github/workflows/flutter-linux.yml b/.github/workflows/flutter-linux.yml index 2d24dd0e..3ce4efad 100644 --- a/.github/workflows/flutter-linux.yml +++ b/.github/workflows/flutter-linux.yml @@ -100,6 +100,14 @@ jobs: linux/runner/tests/first_weekday_test.cc \ -o build/linux/native-tests/first_weekday_test build/linux/native-tests/first_weekday_test + LC_ALL=C.UTF-8 MALLOC_PERTURB_=165 \ + build/linux/native-tests/first_weekday_test + g++ -std=c++17 -Wall -Wextra -Werror \ + -fsanitize=address -fno-omit-frame-pointer \ + linux/runner/tests/first_weekday_test.cc \ + -o build/linux/native-tests/first_weekday_test_asan + ASAN_OPTIONS=detect_leaks=0 \ + build/linux/native-tests/first_weekday_test_asan g++ -std=c++17 -Wall -Wextra -Werror \ linux/runner/tests/time_picker_test.cc \ -o build/linux/native-tests/time_picker_test \ diff --git a/lib/src/android/presentation/android_date_picker.dart b/lib/src/android/presentation/android_date_picker.dart index 222ad209..5d0b41bf 100644 --- a/lib/src/android/presentation/android_date_picker.dart +++ b/lib/src/android/presentation/android_date_picker.dart @@ -62,13 +62,12 @@ Future showBusyMaxDatePicker({ anchorPoint: anchorPoint, builder: (pickerContext, child) { final actual = MaterialLocalizations.of(pickerContext); - final firstWeekday = - BusyMaxWeekPreferencesScope.firstWeekdayOf(pickerContext); + final firstWeekday = BusyMaxWeekPreferencesScope.firstWeekdayOf( + pickerContext, + ); final localizedChild = Localizations.override( context: pickerContext, - delegates: [ - _BusyMaxPickerLocalizationsDelegate(actual, firstWeekday), - ], + delegates: [_BusyMaxPickerLocalizationsDelegate(actual, firstWeekday)], child: child!, ); return builder?.call(pickerContext, localizedChild) ?? localizedChild; @@ -97,10 +96,7 @@ class _BusyMaxPickerLocalizationsDelegate } class _BusyMaxPickerMaterialLocalizations implements MaterialLocalizations { - const _BusyMaxPickerMaterialLocalizations( - this._delegate, - this._firstWeekday, - ); + const _BusyMaxPickerMaterialLocalizations(this._delegate, this._firstWeekday); final MaterialLocalizations _delegate; final int _firstWeekday; @@ -132,19 +128,33 @@ class _BusyMaxPickerMaterialLocalizations implements MaterialLocalizations { @override String get showMenuTooltip => _delegate.showMenuTooltip; @override - String aboutListTileTitle(String applicationName) => _delegate.aboutListTileTitle(applicationName); + String aboutListTileTitle(String applicationName) => + _delegate.aboutListTileTitle(applicationName); @override String get licensesPageTitle => _delegate.licensesPageTitle; @override - String licensesPackageDetailText(int licenseCount) => _delegate.licensesPackageDetailText(licenseCount); - @override - String pageRowsInfoTitle(int firstRow, int lastRow, int rowCount, bool rowCountIsApproximate) => _delegate.pageRowsInfoTitle(firstRow, lastRow, rowCount, rowCountIsApproximate); + String licensesPackageDetailText(int licenseCount) => + _delegate.licensesPackageDetailText(licenseCount); + @override + String pageRowsInfoTitle( + int firstRow, + int lastRow, + int rowCount, + bool rowCountIsApproximate, + ) => _delegate.pageRowsInfoTitle( + firstRow, + lastRow, + rowCount, + rowCountIsApproximate, + ); @override String get rowsPerPageTitle => _delegate.rowsPerPageTitle; @override - String tabLabel({required int tabIndex, required int tabCount}) => _delegate.tabLabel(tabIndex: tabIndex, tabCount: tabCount); + String tabLabel({required int tabIndex, required int tabCount}) => + _delegate.tabLabel(tabIndex: tabIndex, tabCount: tabCount); @override - String selectedRowCountTitle(int selectedRowCount) => _delegate.selectedRowCountTitle(selectedRowCount); + String selectedRowCountTitle(int selectedRowCount) => + _delegate.selectedRowCountTitle(selectedRowCount); @override String get cancelButtonLabel => _delegate.cancelButtonLabel; @override @@ -176,9 +186,11 @@ class _BusyMaxPickerMaterialLocalizations implements MaterialLocalizations { @override String get postMeridiemAbbreviation => _delegate.postMeridiemAbbreviation; @override - String get timePickerHourModeAnnouncement => _delegate.timePickerHourModeAnnouncement; + String get timePickerHourModeAnnouncement => + _delegate.timePickerHourModeAnnouncement; @override - String get timePickerMinuteModeAnnouncement => _delegate.timePickerMinuteModeAnnouncement; + String get timePickerMinuteModeAnnouncement => + _delegate.timePickerMinuteModeAnnouncement; @override String get modalBarrierDismissLabel => _delegate.modalBarrierDismissLabel; @override @@ -204,19 +216,33 @@ class _BusyMaxPickerMaterialLocalizations implements MaterialLocalizations { @override String get bottomSheetLabel => _delegate.bottomSheetLabel; @override - String scrimOnTapHint(String modalRouteContentName) => _delegate.scrimOnTapHint(modalRouteContentName); + String scrimOnTapHint(String modalRouteContentName) => + _delegate.scrimOnTapHint(modalRouteContentName); @override - TimeOfDayFormat timeOfDayFormat({bool alwaysUse24HourFormat = false}) => _delegate.timeOfDayFormat(alwaysUse24HourFormat: alwaysUse24HourFormat); + TimeOfDayFormat timeOfDayFormat({bool alwaysUse24HourFormat = false}) => + _delegate.timeOfDayFormat(alwaysUse24HourFormat: alwaysUse24HourFormat); @override ScriptCategory get scriptCategory => _delegate.scriptCategory; @override String formatDecimal(int number) => _delegate.formatDecimal(number); @override - String formatHour(TimeOfDay timeOfDay, {bool alwaysUse24HourFormat = false}) => _delegate.formatHour(timeOfDay, alwaysUse24HourFormat: alwaysUse24HourFormat); + String formatHour( + TimeOfDay timeOfDay, { + bool alwaysUse24HourFormat = false, + }) => _delegate.formatHour( + timeOfDay, + alwaysUse24HourFormat: alwaysUse24HourFormat, + ); @override String formatMinute(TimeOfDay timeOfDay) => _delegate.formatMinute(timeOfDay); @override - String formatTimeOfDay(TimeOfDay timeOfDay, {bool alwaysUse24HourFormat = false}) => _delegate.formatTimeOfDay(timeOfDay, alwaysUse24HourFormat: alwaysUse24HourFormat); + String formatTimeOfDay( + TimeOfDay timeOfDay, { + bool alwaysUse24HourFormat = false, + }) => _delegate.formatTimeOfDay( + timeOfDay, + alwaysUse24HourFormat: alwaysUse24HourFormat, + ); @override String formatYear(DateTime date) => _delegate.formatYear(date); @override @@ -230,13 +256,16 @@ class _BusyMaxPickerMaterialLocalizations implements MaterialLocalizations { @override String formatMonthYear(DateTime date) => _delegate.formatMonthYear(date); @override - String formatShortMonthDay(DateTime date) => _delegate.formatShortMonthDay(date); + String formatShortMonthDay(DateTime date) => + _delegate.formatShortMonthDay(date); @override - DateTime? parseCompactDate(String? inputString) => _delegate.parseCompactDate(inputString); + DateTime? parseCompactDate(String? inputString) => + _delegate.parseCompactDate(inputString); @override List get narrowWeekdays => _delegate.narrowWeekdays; @override - int get firstDayOfWeekIndex => _firstWeekday == DateTime.sunday ? 0 : _firstWeekday; + int get firstDayOfWeekIndex => + _firstWeekday == DateTime.sunday ? 0 : _firstWeekday; @override String get dateSeparator => _delegate.dateSeparator; @override @@ -254,9 +283,11 @@ class _BusyMaxPickerMaterialLocalizations implements MaterialLocalizations { @override String get dateRangeEndLabel => _delegate.dateRangeEndLabel; @override - String dateRangeStartDateSemanticLabel(String formattedDate) => _delegate.dateRangeStartDateSemanticLabel(formattedDate); + String dateRangeStartDateSemanticLabel(String formattedDate) => + _delegate.dateRangeStartDateSemanticLabel(formattedDate); @override - String dateRangeEndDateSemanticLabel(String formattedDate) => _delegate.dateRangeEndDateSemanticLabel(formattedDate); + String dateRangeEndDateSemanticLabel(String formattedDate) => + _delegate.dateRangeEndDateSemanticLabel(formattedDate); @override String get invalidDateFormatLabel => _delegate.invalidDateFormatLabel; @override @@ -314,17 +345,21 @@ class _BusyMaxPickerMaterialLocalizations implements MaterialLocalizations { @override String get expansionTileCollapsedHint => _delegate.expansionTileCollapsedHint; @override - String get expansionTileExpandedTapHint => _delegate.expansionTileExpandedTapHint; + String get expansionTileExpandedTapHint => + _delegate.expansionTileExpandedTapHint; @override - String get expansionTileCollapsedTapHint => _delegate.expansionTileCollapsedTapHint; + String get expansionTileCollapsedTapHint => + _delegate.expansionTileCollapsedTapHint; @override String get expandedHint => _delegate.expandedHint; @override String get collapsedHint => _delegate.collapsedHint; @override - String remainingTextFieldCharacterCount(int remaining) => _delegate.remainingTextFieldCharacterCount(remaining); + String remainingTextFieldCharacterCount(int remaining) => + _delegate.remainingTextFieldCharacterCount(remaining); @override - String get refreshIndicatorSemanticLabel => _delegate.refreshIndicatorSemanticLabel; + String get refreshIndicatorSemanticLabel => + _delegate.refreshIndicatorSemanticLabel; @override String get keyboardKeyAlt => _delegate.keyboardKeyAlt; @override @@ -398,7 +433,8 @@ class _BusyMaxPickerMaterialLocalizations implements MaterialLocalizations { @override String get keyboardKeyNumpadParenLeft => _delegate.keyboardKeyNumpadParenLeft; @override - String get keyboardKeyNumpadParenRight => _delegate.keyboardKeyNumpadParenRight; + String get keyboardKeyNumpadParenRight => + _delegate.keyboardKeyNumpadParenRight; @override String get keyboardKeyNumpadSubtract => _delegate.keyboardKeyNumpadSubtract; @override diff --git a/lib/src/app/busymax_app.dart b/lib/src/app/busymax_app.dart index 80ca6a88..0e615fc0 100644 --- a/lib/src/app/busymax_app.dart +++ b/lib/src/app/busymax_app.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:logging/logging.dart'; import 'package:system_theme/system_theme.dart'; import 'package:ubuntu_localizations/ubuntu_localizations.dart'; @@ -30,6 +31,8 @@ import 'system_accent.dart'; import 'app_theme.dart'; import '../features/settings/presentation/settings_screen.dart'; import '../features/calendar/presentation/ical_import_flow.dart'; +import '../core/logging/redacting_logger.dart'; +import 'common/desktop_calendar_open_readiness.dart'; typedef BusyMaxTrayServiceFactory = BusyMaxTrayService Function(BusyMaxTrayServiceConfiguration configuration); @@ -89,6 +92,8 @@ class BusyMaxApp extends LinuxBusyMaxApp { } class _BusyMaxAppState extends ConsumerState { + final _logger = RedactingLogger(Logger('LinuxBusyMaxApp')); + final _calendarOpenReadiness = DesktopCalendarOpenReadiness(); late final BusyMaxSystemFirstWeekdayController _firstWeekdayController; BusyMaxTrayService? _trayService; bool? _lastHideOnClose; @@ -121,9 +126,14 @@ class _BusyMaxAppState extends ConsumerState { .read(desktopActivationServiceProvider) .activations .listen((request) { - _externalOpenTail = _externalOpenTail - .catchError((Object _) {}) - .then((_) => _handleExternalCalendarOpen(request)); + final operation = _externalOpenTail.then( + (_) => _handleExternalCalendarOpen(request), + ); + _externalOpenTail = operation.catchError((Object error) { + _logger.warning( + 'Calendar-open operation failed (${error.runtimeType}).', + ); + }); }); _navigationSubscription = ref .read(desktopNavigationServiceProvider) @@ -134,6 +144,7 @@ class _BusyMaxAppState extends ConsumerState { @override void dispose() { + _calendarOpenReadiness.dispose(); _firstWeekdayController ..removeListener(_weekPreferenceChanged) ..dispose(); @@ -152,7 +163,29 @@ class _BusyMaxAppState extends ConsumerState { } Future _handleExternalCalendarOpen(DesktopActivation request) async { - if (!mounted) return; + if (request.kind != DesktopActivationKind.webCal && + request.kind != DesktopActivationKind.icsFile) { + return; + } + if (!mounted || !_calendarOpenReadiness.isActive) return; + if (!await _calendarOpenReadiness.waitFor( + ref.read(appSettingsControllerProvider.notifier).ready, + )) { + return; + } + if (!mounted || !_calendarOpenReadiness.isActive) return; + final settings = ref.read(appSettingsControllerProvider); + if (settings.firstDayOfWeekPreference == + BusyMaxFirstDayOfWeekPreference.system && + !_firstWeekdayController.isInitialized) { + if (!await _calendarOpenReadiness.waitFor( + _firstWeekdayController.ready, + )) { + return; + } + } + if (!mounted || !_calendarOpenReadiness.isActive) return; + final router = ref.read(appRouterProvider); switch (request.kind) { case DesktopActivationKind.webCal: @@ -165,10 +198,18 @@ class _BusyMaxAppState extends ConsumerState { case DesktopActivationKind.notification: return; } - await WidgetsBinding.instance.endOfFrame; - if (!mounted) return; - final dialogContext = rootNavigatorKey.currentContext; - if (dialogContext == null || !dialogContext.mounted) return; + final dialogContext = await _calendarOpenReadiness.waitForRootNavigator( + rootNavigatorKey, + ); + if (!mounted || + dialogContext == null || + !dialogContext.mounted || + !_calendarOpenReadiness.isUsableRootNavigator( + rootNavigatorKey, + dialogContext, + )) { + return; + } switch (request.kind) { case DesktopActivationKind.webCal: await showAddCalendarSubscriptionFlow( diff --git a/lib/src/app/common/desktop_calendar_open_readiness.dart b/lib/src/app/common/desktop_calendar_open_readiness.dart new file mode 100644 index 00000000..43f726d9 --- /dev/null +++ b/lib/src/app/common/desktop_calendar_open_readiness.dart @@ -0,0 +1,58 @@ +import 'dart:async'; + +import 'package:flutter/widgets.dart'; + +/// Cancellable, frame-driven readiness checks for desktop calendar-open flows. +final class DesktopCalendarOpenReadiness { + final _disposed = Completer(); + + bool get isActive => !_disposed.isCompleted; + + Future waitFor(Future future) async { + if (!isActive) return false; + final completed = await Future.any([ + future.then((_) => true), + _disposed.future.then((_) => false), + ]); + return completed && isActive; + } + + Future waitForRootNavigator( + GlobalKey navigatorKey, + ) async { + while (isActive) { + if (!await waitFor(WidgetsBinding.instance.endOfFrame)) return null; + final navigator = navigatorKey.currentState; + final overlay = navigator?.overlay; + final context = navigatorKey.currentContext; + if (navigator != null && + navigator.mounted && + overlay != null && + overlay.mounted && + context != null && + context.mounted) { + return context; + } + } + return null; + } + + bool isUsableRootNavigator( + GlobalKey navigatorKey, + BuildContext context, + ) { + final navigator = navigatorKey.currentState; + final overlay = navigator?.overlay; + return isActive && + context.mounted && + identical(context, navigatorKey.currentContext) && + navigator != null && + navigator.mounted && + overlay != null && + overlay.mounted; + } + + void dispose() { + if (!_disposed.isCompleted) _disposed.complete(); + } +} diff --git a/lib/src/app/windows/windows_busymax_app.dart b/lib/src/app/windows/windows_busymax_app.dart index 5ac21c29..c89da236 100644 --- a/lib/src/app/windows/windows_busymax_app.dart +++ b/lib/src/app/windows/windows_busymax_app.dart @@ -3,9 +3,11 @@ import 'dart:async'; import 'package:fluent_ui/fluent_ui.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; +import 'package:logging/logging.dart'; import '../../../l10n/generated/app_localizations.dart'; import '../../features/auth/data/auth_repository.dart'; +import '../../core/logging/redacting_logger.dart'; import '../../l10n/locale_resolution.dart'; import '../../l10n/time_format_scope.dart'; import '../../l10n/week_preferences_scope.dart'; @@ -20,6 +22,7 @@ import '../../ui/windows/windows_workspace_shell.dart'; import '../../ui/windows/windows_desktop_runtime.dart'; import '../../ui/windows/windows_calendar_activation_flows.dart'; import '../app_bootstrap.dart'; +import '../common/desktop_calendar_open_readiness.dart'; final windowsRootNavigatorKey = GlobalKey(); @@ -69,11 +72,14 @@ class WindowsBusyMaxApp extends ConsumerStatefulWidget { class _WindowsBusyMaxAppState extends ConsumerState with WidgetsBindingObserver { + final _logger = RedactingLogger(Logger('WindowsBusyMaxApp')); + final _calendarOpenReadiness = DesktopCalendarOpenReadiness(); StreamSubscription? _activationSubscription; StreamSubscription? _navigationSubscription; StreamSubscription? _appearanceSubscription; late final WindowsClockPreference _clockPreference; late final BusyMaxSystemFirstWeekdayController _firstWeekdayController; + Future _calendarOpenTail = Future.value(); @override void initState() { @@ -109,6 +115,11 @@ class _WindowsBusyMaxAppState extends ConsumerState Future _handleActivation(DesktopActivation activation) async { if (!activation.isValid) return; + if (activation.kind == DesktopActivationKind.icsFile || + activation.kind == DesktopActivationKind.webCal) { + _queueCalendarOpen(activation); + return; + } if (activation.requiresVisibleWindow) { await ref.read(desktopWindowServiceProvider).showWindow(); } @@ -118,27 +129,8 @@ class _WindowsBusyMaxAppState extends ConsumerState case DesktopActivationKind.startMinimized: return; case DesktopActivationKind.icsFile: - windowsAppRouter.go('/schedule'); - await WidgetsBinding.instance.endOfFrame; - final context = windowsRootNavigatorKey.currentContext; - if (context != null && context.mounted) { - await showWindowsIcsImportFlow( - context, - ref, - filePath: activation.value, - ); - } case DesktopActivationKind.webCal: - windowsAppRouter.go('/settings'); - await WidgetsBinding.instance.endOfFrame; - final context = windowsRootNavigatorKey.currentContext; - if (context != null && context.mounted) { - await showWindowsWebCalSubscriptionFlow( - context, - ref, - initialUrl: activation.value, - ); - } + return; case DesktopActivationKind.notification: final destination = activation.notificationDestination; if (destination != null) { @@ -161,6 +153,80 @@ class _WindowsBusyMaxAppState extends ConsumerState } } + void _queueCalendarOpen(DesktopActivation activation) { + final operation = _calendarOpenTail.then((_) async { + if (!mounted || !_calendarOpenReadiness.isActive) return; + await ref.read(desktopWindowServiceProvider).showWindow(); + if (!mounted || !_calendarOpenReadiness.isActive) return; + await _openCalendarActivation(activation); + }); + _calendarOpenTail = operation.catchError((Object error) { + _logger.warning('Calendar-open operation failed (${error.runtimeType}).'); + }); + } + + Future _openCalendarActivation(DesktopActivation activation) async { + if (!mounted || !_calendarOpenReadiness.isActive) return; + if (!await _calendarOpenReadiness.waitFor( + ref.read(appSettingsControllerProvider.notifier).ready, + )) { + return; + } + if (!mounted || !_calendarOpenReadiness.isActive) return; + final settings = ref.read(appSettingsControllerProvider); + if (settings.firstDayOfWeekPreference == + BusyMaxFirstDayOfWeekPreference.system && + !_firstWeekdayController.isInitialized) { + if (!await _calendarOpenReadiness.waitFor( + _firstWeekdayController.ready, + )) { + return; + } + } + if (!mounted || !_calendarOpenReadiness.isActive) return; + + switch (activation.kind) { + case DesktopActivationKind.icsFile: + windowsAppRouter.go('/schedule'); + case DesktopActivationKind.webCal: + windowsAppRouter.go('/settings'); + case DesktopActivationKind.normalLaunch: + case DesktopActivationKind.startMinimized: + case DesktopActivationKind.notification: + return; + } + final context = await _calendarOpenReadiness.waitForRootNavigator( + windowsRootNavigatorKey, + ); + if (!mounted || + context == null || + !context.mounted || + !_calendarOpenReadiness.isUsableRootNavigator( + windowsRootNavigatorKey, + context, + )) { + return; + } + switch (activation.kind) { + case DesktopActivationKind.icsFile: + await showWindowsIcsImportFlow( + context, + ref, + filePath: activation.value, + ); + case DesktopActivationKind.webCal: + await showWindowsWebCalSubscriptionFlow( + context, + ref, + initialUrl: activation.value, + ); + case DesktopActivationKind.normalLaunch: + case DesktopActivationKind.startMinimized: + case DesktopActivationKind.notification: + return; + } + } + void _handleNavigation(DesktopNavigationRequest request) { windowsAppRouter.go(switch (request.destination) { DesktopNavigationDestination.schedule => '/schedule', @@ -172,6 +238,7 @@ class _WindowsBusyMaxAppState extends ConsumerState @override void dispose() { + _calendarOpenReadiness.dispose(); WidgetsBinding.instance.removeObserver(this); _clockPreference.dispose(); _firstWeekdayController diff --git a/linux/runner/first_weekday.h b/linux/runner/first_weekday.h index 123329d4..7c66da6a 100644 --- a/linux/runner/first_weekday.h +++ b/linux/runner/first_weekday.h @@ -4,24 +4,27 @@ #include #include +#include #include // Reads the effective LC_TIME convention using glibc's private locale items. // // _NL_TIME_FIRST_WEEKDAY is a one-byte offset from the date encoded by -// _NL_TIME_WEEK_1STDAY. For the numeric item, glibc returns the word in the -// pointer value itself; the returned address must not be dereferenced. -inline std::optional BusyMaxReadFirstWeekday() { -#if defined(__GLIBC__) && defined(__USE_GNU) - const char* relative_bytes = nl_langinfo(_NL_TIME_FIRST_WEEKDAY); - if (relative_bytes == nullptr) return std::nullopt; +// _NL_TIME_WEEK_1STDAY. glibc stores the numeric item in a union whose pointer +// member is returned by nl_langinfo, while GTK reads the union's word member. +// Decode that word from the local pointer object's representation; the address +// contained in the returned pointer is opaque and must not be dereferenced. +namespace busymax_internal { + +inline std::optional DecodeFirstWeekday(unsigned relative_weekday, + const char* raw_origin) { + static_assert(sizeof(raw_origin) >= sizeof(std::uint32_t), + "locale pointer representation must contain the origin word"); - const unsigned relative_weekday = - static_cast(relative_bytes[0]); if (relative_weekday < 1 || relative_weekday > 7) return std::nullopt; - const std::uintptr_t origin_word = reinterpret_cast( - nl_langinfo(_NL_TIME_WEEK_1STDAY)); + std::uint32_t origin_word = 0; + std::memcpy(&origin_word, &raw_origin, sizeof(origin_word)); int origin_weekday = 0; if (origin_word == 19971130U) { origin_weekday = 7; // Sunday in Dart numbering. @@ -32,6 +35,19 @@ inline std::optional BusyMaxReadFirstWeekday() { } return ((origin_weekday + static_cast(relative_weekday) - 2) % 7) + 1; +} + +} // namespace busymax_internal + +inline std::optional BusyMaxReadFirstWeekday() { +#if defined(__GLIBC__) && defined(__USE_GNU) + const char* relative_bytes = nl_langinfo(_NL_TIME_FIRST_WEEKDAY); + if (relative_bytes == nullptr) return std::nullopt; + + const unsigned relative_weekday = + static_cast(relative_bytes[0]); + const char* raw_origin = nl_langinfo(_NL_TIME_WEEK_1STDAY); + return busymax_internal::DecodeFirstWeekday(relative_weekday, raw_origin); #else return std::nullopt; #endif diff --git a/linux/runner/tests/first_weekday_test.cc b/linux/runner/tests/first_weekday_test.cc index 7e75a7a2..15b38f17 100644 --- a/linux/runner/tests/first_weekday_test.cc +++ b/linux/runner/tests/first_weekday_test.cc @@ -1,9 +1,12 @@ #include "../first_weekday.h" +#include #include #include +#include #include #include +#include #include namespace { @@ -17,9 +20,44 @@ void RequireLocale(const char* category_name, int category, } } +const char* SyntheticOrigin(std::uint32_t origin_word, unsigned char fill) { + std::array storage; + storage.fill(fill); + std::memcpy(storage.data(), &origin_word, sizeof(origin_word)); + + const char* opaque = nullptr; + std::memcpy(&opaque, storage.data(), sizeof(opaque)); + return opaque; +} + +void TestSyntheticOrigin(std::uint32_t origin_word, + const std::array& expected) { + for (unsigned relative_weekday = 1; relative_weekday <= 7; + ++relative_weekday) { + const auto zero_filled = busymax_internal::DecodeFirstWeekday( + relative_weekday, SyntheticOrigin(origin_word, 0)); + const auto nonzero_filled = busymax_internal::DecodeFirstWeekday( + relative_weekday, SyntheticOrigin(origin_word, 0xA5)); + assert(zero_filled == expected[relative_weekday - 1]); + assert(nonzero_filled == zero_filled); + } +} + } // namespace int main() { + TestSyntheticOrigin(19971130U, {7, 1, 2, 3, 4, 5, 6}); + TestSyntheticOrigin(19971201U, {1, 2, 3, 4, 5, 6, 7}); + assert(!busymax_internal::DecodeFirstWeekday( + 0, SyntheticOrigin(19971130U, 0xA5)) + .has_value()); + assert(!busymax_internal::DecodeFirstWeekday( + 8, SyntheticOrigin(19971201U, 0xA5)) + .has_value()); + assert(!busymax_internal::DecodeFirstWeekday( + 1, SyntheticOrigin(20000101U, 0xA5)) + .has_value()); + // Execute the real glibc reader in the process's effective locale. This // catches both accidental preprocessing-out and invalid pointer decoding. assert(std::setlocale(LC_ALL, "") != nullptr); @@ -36,5 +74,8 @@ int main() { RequireLocale("LC_TIME", LC_TIME, "en_US.UTF-8"); assert(BusyMaxReadFirstWeekday() == 7); // Sunday. + + RequireLocale("LC_TIME", LC_TIME, "C.UTF-8"); + assert(BusyMaxReadFirstWeekday() == 7); // Sunday. return 0; } diff --git a/test/app/first_day_of_week_test.dart b/test/app/first_day_of_week_test.dart index 43e3ed99..aa20ecd5 100644 --- a/test/app/first_day_of_week_test.dart +++ b/test/app/first_day_of_week_test.dart @@ -221,6 +221,7 @@ void main() { source.reads.single.complete(DateTime.saturday); await tester.pump(); + await tester.pump(); expect(controller.isInitialized, isTrue); expect(find.text('calendar: 6'), findsOneWidget); diff --git a/test/app/weekday_startup_activation_test.dart b/test/app/weekday_startup_activation_test.dart new file mode 100644 index 00000000..45e3ec6d --- /dev/null +++ b/test/app/weekday_startup_activation_test.dart @@ -0,0 +1,655 @@ +import 'dart:async'; +import 'dart:io'; +import 'package:busymax/src/app/app_bootstrap.dart'; +import 'package:busymax/src/app/app_router.dart'; +import 'package:busymax/src/app/busymax_app.dart'; +import 'package:busymax/src/app/system_accent.dart'; +import 'package:busymax/src/app/windows/windows_busymax_app.dart'; +import 'package:busymax/src/config/build_config.dart'; +import 'package:busymax/src/db/app_database.dart'; +import 'package:busymax/src/features/auth/data/auth_repository.dart'; +import 'package:busymax/src/features/connectivity/network_connectivity_service.dart'; +import 'package:busymax/src/l10n/week_preferences_scope.dart'; +import 'package:busymax/src/platform/common/desktop_services.dart'; +import 'package:busymax/src/platform/gtk_font_service.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../support/memory_settings_store.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + for (final platform in _TestPlatform.values) { + group('${platform.name} root activation readiness', () { + testWidgets( + 'retains ICS and webcal requests during delayed automatic startup', + (tester) async { + final native = _NativeWeekdayResponder(platform); + final harness = await _RootHarness.mount( + tester, + platform: platform, + native: native, + signedIn: true, + ); + addTearDown(harness.dispose); + final fixture = await harness.writeIcs('delayed', uid: 'delayed'); + const subscription = 'webcal://calendar.example.test/delayed'; + + harness.activate( + DesktopActivation( + kind: DesktopActivationKind.icsFile, + value: fixture.path, + ), + ); + await harness.pumpFrames(3); + expect(find.text(_importTitle), findsNothing); + expect(_editable(subscription), findsNothing); + + native.succeed(DateTime.tuesday); + await harness.pumpUntil(find.text(_importTitle)); + expect(find.text(_importTitle), findsOneWidget); + expect(harness.effectiveFirstWeekday, DateTime.tuesday); + expect(native.weekdayReads, 1); + + if (platform == _TestPlatform.windows) { + harness.activate( + const DesktopActivation( + kind: DesktopActivationKind.notification, + action: 'open', + payload: {'notificationRoute': 'due-today'}, + ), + ); + await harness.pumpFrames(1); + expect(windowsAppRouter.state.uri.path, '/tasks'); + } + + if (find.text('Cancel').evaluate().isNotEmpty) { + await harness.dismiss('Cancel'); + } + harness.activate( + const DesktopActivation( + kind: DesktopActivationKind.webCal, + value: subscription, + ), + ); + await harness.pumpUntil(_editable(subscription)); + expect(_editableValues(tester), contains(subscription)); + expect(_editable(subscription), findsOneWidget); + await harness.dismiss('Cancel'); + await harness.pumpFrames(3); + expect(find.text(_importTitle), findsNothing); + expect(_editable(subscription), findsNothing); + + const afterStartup = + 'webcal://calendar.example.test/already-initialized'; + harness.activate( + const DesktopActivation( + kind: DesktopActivationKind.webCal, + value: afterStartup, + ), + ); + await harness.pumpUntil(_editable(afterStartup)); + expect(_editableValues(tester), contains(afterStartup)); + expect(native.weekdayReads, 1); + await harness.dismiss('Cancel'); + await harness.dispose(); + }, + ); + + testWidgets('presents multiple startup requests once and in order', ( + tester, + ) async { + final native = _NativeWeekdayResponder(platform); + final harness = await _RootHarness.mount( + tester, + platform: platform, + native: native, + ); + addTearDown(harness.dispose); + const first = 'webcal://calendar.example.test/first'; + const second = 'webcal://calendar.example.test/second'; + + harness.activate( + const DesktopActivation( + kind: DesktopActivationKind.webCal, + value: first, + ), + ); + harness.activate( + const DesktopActivation( + kind: DesktopActivationKind.webCal, + value: second, + ), + ); + await harness.pumpFrames(4); + expect(_editable(first), findsNothing); + expect(_editable(second), findsNothing); + + native.succeed(DateTime.saturday); + await harness.pumpUntil(_editable(first)); + expect(_editableValues(tester), contains(first)); + expect(_editableValues(tester), isNot(contains(second))); + await harness.dismiss('Cancel'); + + await harness.pumpUntil( + find.byWidgetPredicate( + (widget) => + widget is EditableText && widget.controller.text == second, + ), + ); + expect(_editable(second), findsOneWidget); + expect(_editableValues(tester), contains(second)); + await harness.dismiss('Cancel'); + await harness.pumpFrames(4); + expect(_editable(first), findsNothing); + expect(_editable(second), findsNothing); + await harness.dispose(); + }); + + for (final outcome in _FallbackOutcome.values) { + testWidgets('processes a queued request after ${outcome.name}', ( + tester, + ) async { + final native = _NativeWeekdayResponder(platform); + final harness = await _RootHarness.mount( + tester, + platform: platform, + native: native, + ); + addTearDown(harness.dispose); + final url = 'webcal://calendar.example.test/${outcome.name}'; + + harness.activate( + DesktopActivation(kind: DesktopActivationKind.webCal, value: url), + ); + await harness.pumpFrames(2); + expect(_editable(url), findsNothing); + switch (outcome) { + case _FallbackOutcome.nativeUnavailable: + native.succeed(null); + case _FallbackOutcome.platformFailure: + native.fail(); + case _FallbackOutcome.timeout: + await tester.pump(const Duration(seconds: 2, milliseconds: 1)); + } + + await harness.pumpUntil(_editable(url)); + expect(_editableValues(tester), contains(url)); + expect(harness.effectiveFirstWeekday, DateTime.sunday); + expect(native.weekdayReads, 1); + await harness.dismiss('Cancel'); + await harness.dispose(); + }); + } + + testWidgets( + 'loads a persisted manual preference before skipping native readiness', + (tester) async { + final native = _NativeWeekdayResponder(platform); + final settings = _DelayedSettingsStore(); + final harness = await _RootHarness.mount( + tester, + platform: platform, + native: native, + settingsStore: settings, + ); + addTearDown(harness.dispose); + const url = 'webcal://calendar.example.test/manual'; + + harness.activate( + const DesktopActivation( + kind: DesktopActivationKind.webCal, + value: url, + ), + ); + await harness.pumpFrames(3); + expect(_editable(url), findsNothing); + + settings.complete({ + 'firstDayOfWeekPreference': 'wednesday', + 'showTrayIcon': false, + 'runInBackgroundWhenClosed': false, + }); + await harness.pumpUntil(_editable(url)); + expect(_editableValues(tester), contains(url)); + expect(harness.effectiveFirstWeekday, DateTime.wednesday); + expect(native.isPending, isTrue); + await harness.dismiss('Cancel'); + await harness.dispose(); + }, + ); + + testWidgets('disposal cancels native and navigator-frame waits', ( + tester, + ) async { + final nativeWait = _NativeWeekdayResponder(platform); + final first = await _RootHarness.mount( + tester, + platform: platform, + native: nativeWait, + ); + addTearDown(first.dispose); + first.activate( + const DesktopActivation( + kind: DesktopActivationKind.webCal, + value: 'webcal://calendar.example.test/dispose-native', + ), + ); + await first.pumpFrames(2); + await first.removeApp(); + nativeWait.succeed(DateTime.friday); + await first.pumpFrames(3); + expect(find.text(_subscriptionTitle), findsNothing); + expect(tester.takeException(), isNull); + await first.dispose(); + + final frameWait = _NativeWeekdayResponder(platform); + final second = await _RootHarness.mount( + tester, + platform: platform, + native: frameWait, + ); + addTearDown(second.dispose); + second.activate( + const DesktopActivation( + kind: DesktopActivationKind.webCal, + value: 'webcal://calendar.example.test/dispose-frame', + ), + ); + frameWait.succeed(DateTime.thursday); + await tester.idle(); + await second.removeApp(); + await second.pumpFrames(3); + expect(find.text(_subscriptionTitle), findsNothing); + expect(tester.takeException(), isNull); + await second.dispose(); + }); + + testWidgets('continues after the first flow takes its error path', ( + tester, + ) async { + final native = _NativeWeekdayResponder(platform) + ..succeed(DateTime.monday); + final harness = await _RootHarness.mount( + tester, + platform: platform, + native: native, + signedIn: true, + ); + addTearDown(harness.dispose); + final missing = '${harness.directory.path}/missing.ics'; + const next = 'webcal://calendar.example.test/after-error'; + + harness.activate( + DesktopActivation( + kind: DesktopActivationKind.icsFile, + value: missing, + ), + ); + harness.activate( + const DesktopActivation( + kind: DesktopActivationKind.webCal, + value: next, + ), + ); + + if (platform == _TestPlatform.windows) { + await harness.pumpUntil(find.textContaining('Could not import')); + expect(_editable(next), findsNothing); + await harness.dismiss('Close'); + } + await harness.pumpUntil(_editable(next)); + expect(_editableValues(tester), contains(next)); + if (platform == _TestPlatform.linux) { + expect(find.textContaining('Could not import'), findsOneWidget); + } + await harness.dismiss('Cancel'); + await harness.dispose(); + }); + }); + } +} + +const _importTitle = 'Import calendar events'; +const _subscriptionTitle = 'Add calendar subscription'; + +enum _TestPlatform { + linux('io.busystack.busymax/gtk_settings'), + windows('busymax/windows_weekday'); + + const _TestPlatform(this.weekdayChannel); + final String weekdayChannel; +} + +enum _FallbackOutcome { nativeUnavailable, platformFailure, timeout } + +final class _RootHarness { + _RootHarness._({ + required this.tester, + required this.platform, + required this.native, + required this.database, + required this.activations, + required this.container, + required this.directory, + required this.networkMonitor, + }); + + final WidgetTester tester; + final _TestPlatform platform; + final _NativeWeekdayResponder native; + final AppDatabase database; + final _ActivationService activations; + final ProviderContainer container; + final Directory directory; + final NetworkConnectivityMonitor networkMonitor; + var _disposed = false; + var _appMounted = true; + + static Future<_RootHarness> mount( + WidgetTester tester, { + required _TestPlatform platform, + required _NativeWeekdayResponder native, + LocalSettingsStore? settingsStore, + bool signedIn = false, + }) async { + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + messenger.setMockMessageHandler( + platform.weekdayChannel, + native.handleMessage, + ); + if (platform == _TestPlatform.linux) { + messenger.setMockMethodCallHandler( + const MethodChannel('yaru_window'), + (call) async => call.method == 'state' ? {} : null, + ); + messenger.setMockMethodCallHandler( + const MethodChannel('yaru_window/events'), + (call) async => null, + ); + } + late Directory directory; + await tester.runAsync(() async { + directory = await Directory.systemTemp.createTemp( + 'busymax-weekday-activation-', + ); + }); + final database = AppDatabase.memoryForTests(); + final activations = _ActivationService(); + final networkMonitor = + NetworkConnectivityMonitor.withoutPlatformObservation(); + final initialSettings = settingsStore == null + ? AppSettings.defaults().copyWith( + showTrayIcon: false, + runInBackgroundWhenClosed: false, + ) + : null; + final overrides = [ + buildConfigProvider.overrideWithValue(_missingConfig), + databaseProvider.overrideWithValue(database), + if (initialSettings != null) + initialAppSettingsProvider.overrideWithValue(initialSettings), + localSettingsStoreProvider.overrideWithValue( + settingsStore ?? MemorySettingsStore(), + ), + desktopActivationServiceProvider.overrideWithValue(activations), + desktopWindowServiceProvider.overrideWithValue( + const NoOpDesktopWindowService(), + ), + networkConnectivityMonitorProvider.overrideWithValue(networkMonitor), + networkAvailabilityProvider.overrideWith( + (ref) => Stream.value(NetworkAvailability.online), + ), + authSessionControllerProvider.overrideWith( + (ref) => signedIn ? _SignedInController() : _SignedOutController(), + ), + if (platform == _TestPlatform.linux) ...[ + gtkFontSettingsProvider.overrideWith((ref) => Stream.value(null)), + gtkThemeColorsProvider.overrideWith((ref) => Stream.value(null)), + ubuntuSystemAccentColorProvider.overrideWith( + (ref) => const Stream.empty(), + ), + ], + if (platform == _TestPlatform.windows) + systemAppearanceSourceProvider.overrideWithValue(const _Appearance()), + ]; + final container = ProviderContainer(overrides: overrides); + tester.view.physicalSize = const Size(1280, 900); + tester.view.devicePixelRatio = 1; + tester.binding.platformDispatcher.localeTestValue = const Locale( + 'en', + 'US', + ); + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: platform == _TestPlatform.linux + ? const LinuxBusyMaxApp() + : const WindowsBusyMaxApp(), + ), + ); + await tester.pump(); + return _RootHarness._( + tester: tester, + platform: platform, + native: native, + database: database, + activations: activations, + container: container, + directory: directory, + networkMonitor: networkMonitor, + ); + } + + int get effectiveFirstWeekday { + final context = platform == _TestPlatform.linux + ? rootNavigatorKey.currentContext + : windowsRootNavigatorKey.currentContext; + expect(context, isNotNull); + return BusyMaxWeekPreferencesScope.firstWeekdayOf(context!); + } + + void activate(DesktopActivation activation) => activations.add(activation); + + Future writeIcs(String name, {required String uid}) async { + final fixture = File('${directory.path}/$name.ics'); + await tester.runAsync( + () => fixture.writeAsString(_validIcs.replaceFirst('TEST_UID', uid)), + ); + return fixture; + } + + Future pumpFrames(int count) async { + for (var index = 0; index < count; index += 1) { + await tester.pump(); + } + } + + Future pumpUntil(Finder finder) async { + for (var attempt = 0; attempt < 30; attempt += 1) { + if (finder.evaluate().isNotEmpty) return; + await tester.runAsync( + () => Future.delayed(const Duration(milliseconds: 20)), + ); + await tester.pump(const Duration(milliseconds: 20)); + } + expect(finder, findsWidgets, reason: 'Timed out waiting for $finder'); + } + + Future dismiss(String label) async { + final button = find.text(label); + expect(button, findsWidgets); + await tester.tap(button.last); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 250)); + } + + Future removeApp() async { + if (!_appMounted) return; + _appMounted = false; + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pump(); + } + + Future dispose() async { + if (_disposed) return; + _disposed = true; + await removeApp(); + container.dispose(); + native.finishPending(); + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + messenger.setMockMessageHandler(platform.weekdayChannel, null); + if (platform == _TestPlatform.linux) { + messenger.setMockMethodCallHandler( + const MethodChannel('yaru_window'), + null, + ); + messenger.setMockMethodCallHandler( + const MethodChannel('yaru_window/events'), + null, + ); + } + await tester.runAsync(() async { + await activations.dispose(); + await networkMonitor.dispose(); + await database.close(); + if (await directory.exists()) await directory.delete(recursive: true); + }); + tester.view.resetPhysicalSize(); + tester.view.resetDevicePixelRatio(); + tester.binding.platformDispatcher.clearLocaleTestValue(); + } +} + +final class _NativeWeekdayResponder { + _NativeWeekdayResponder(this.platform); + + final _TestPlatform platform; + final _response = Completer(); + static const _codec = StandardMethodCodec(); + var weekdayReads = 0; + + bool get isPending => !_response.isCompleted; + + Future handleMessage(ByteData? message) { + final call = _codec.decodeMethodCall(message); + if (call.method == 'getFirstWeekday') { + weekdayReads += 1; + return _response.future; + } + return Future.value(_codec.encodeSuccessEnvelope(null)); + } + + void succeed(int? weekday) { + if (!_response.isCompleted) { + _response.complete(_codec.encodeSuccessEnvelope(weekday)); + } + } + + void fail() { + if (!_response.isCompleted) { + _response.complete( + _codec.encodeErrorEnvelope( + code: 'native-failure', + message: '${platform.name} weekday unavailable', + ), + ); + } + } + + void finishPending() => succeed(null); +} + +final class _ActivationService implements DesktopActivationService { + final _controller = StreamController.broadcast(); + + void add(DesktopActivation activation) => _controller.add(activation); + + @override + Stream get activations => _controller.stream; + + @override + Future initialize() async {} + + @override + Future dispose() => _controller.close(); +} + +final class _SignedOutController extends StateNotifier + implements AuthSessionController { + _SignedOutController() : super(const AuthSessionState.signedOut()); + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +final class _SignedInController extends StateNotifier + implements AuthSessionController { + _SignedInController() + : super(const AuthSessionState.signedIn('test-account')); + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +final class _Appearance implements SystemAppearanceSource { + const _Appearance(); + + @override + Color? get accentColor => null; + + @override + Brightness get brightness => Brightness.light; + + @override + bool get highContrast => false; + + @override + Stream get changes => const Stream.empty(); +} + +final class _DelayedSettingsStore implements LocalSettingsStore { + final _load = Completer>(); + + void complete(Map settings) => _load.complete(settings); + + @override + Future> load() => _load.future; + + @override + Future save(Map json) async {} +} + +List _editableValues(WidgetTester tester) => tester + .widgetList(find.byType(EditableText)) + .map((widget) => widget.controller.text) + .toList(growable: false); + +Finder _editable(String value) => find.byWidgetPredicate( + (widget) => widget is EditableText && widget.controller.text == value, +); + +const _missingConfig = BuildConfig( + googleOAuthClientId: '', + googleOAuthClientSecret: '', + oauthAuthorizationEndpoint: 'https://example.test/authorize', + oauthTokenEndpoint: 'https://example.test/token', + oauthRevocationEndpoint: 'https://example.test/revoke', +); + +const _validIcs = '''BEGIN:VCALENDAR\r +VERSION:2.0\r +PRODID:-//BusyMax Test//EN\r +BEGIN:VEVENT\r +UID:TEST_UID@example.test\r +DTSTART:20261001T090000Z\r +DTEND:20261001T100000Z\r +SUMMARY:Startup activation\r +END:VEVENT\r +END:VCALENDAR\r +'''; diff --git a/test/features/schedule/presentation/schedule_search_platform_test.dart b/test/features/schedule/presentation/schedule_search_platform_test.dart index d82021d9..b328d6d8 100644 --- a/test/features/schedule/presentation/schedule_search_platform_test.dart +++ b/test/features/schedule/presentation/schedule_search_platform_test.dart @@ -26,6 +26,7 @@ import 'package:busymax/src/ui/windows/windows_schedule_search_pane.dart'; import 'package:busymax/src/ui/windows/windows_schedule_source_pane.dart'; import 'package:drift/drift.dart' hide Column, isNull, isNotNull; import 'package:fluent_ui/fluent_ui.dart' as fluent; +import 'package:flutter/foundation.dart' show ValueListenable; import 'package:flutter/material.dart'; import 'package:yaru/yaru.dart'; import 'package:busymax/src/android/presentation/android_schedule_search_filters.dart'; diff --git a/test/flutter_test_config.dart b/test/flutter_test_config.dart new file mode 100644 index 00000000..3d975786 --- /dev/null +++ b/test/flutter_test_config.dart @@ -0,0 +1,21 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +Future testExecutable(FutureOr Function() testMain) async { + await testMain(); + if (BindingBase.debugBindingType() == null) return; + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + for (final channel in const [ + MethodChannel('io.busystack.busymax/gtk_settings'), + MethodChannel('busymax/windows_weekday'), + ]) { + messenger.setMockMethodCallHandler(channel, (call) async { + if (call.method == 'getFirstWeekday') return null; + return null; + }); + } +} From 7f5b734566306f560cafd81a1bead41b49ae6d78 Mon Sep 17 00:00:00 2001 From: albert Date: Fri, 18 Sep 2026 20:33:29 -0700 Subject: [PATCH 19/25] Fix sync recovery, mutation ordering, calendar navigation, and DAV cancellation regressions. --- .../core/http/request_dispatch_exception.dart | 33 ++ lib/src/dav/http/dav_http_transport.dart | 375 +++++++++--- .../network_connectivity_service.dart | 29 +- .../presentation/schedule_workspace.dart | 30 +- .../sync/pending_op_resolution_service.dart | 179 +++++- .../features/sync/pending_ops_replayer.dart | 129 ++++- .../data/task_lists_repository.dart | 149 +++-- .../features/tasks/data/tasks_repository.dart | 98 ++-- .../api/google_tasks_api_client.dart | 26 +- .../api/microsoft_todo_api_client.dart | 26 +- test/dav/http/dav_http_transport_test.dart | 262 +++++++++ .../schedule_workspace_navigation_test.dart | 189 ++++++ .../features/sync/conflict_detector_test.dart | 75 +++ .../pending_op_resolution_service_test.dart | 370 +++++++++++- .../sync/pending_ops_replayer_test.dart | 548 +++++++++++++++++- .../data/task_lists_repository_test.dart | 22 + test/google_tasks/api/tasks_insert_test.dart | 26 + .../api/microsoft_todo_api_client_test.dart | 29 + ...icrosoft_todo_task_remote_client_test.dart | 44 ++ 19 files changed, 2447 insertions(+), 192 deletions(-) create mode 100644 lib/src/core/http/request_dispatch_exception.dart create mode 100644 test/features/schedule/presentation/schedule_workspace_navigation_test.dart create mode 100644 test/features/sync/conflict_detector_test.dart diff --git a/lib/src/core/http/request_dispatch_exception.dart b/lib/src/core/http/request_dispatch_exception.dart new file mode 100644 index 00000000..7514cc2d --- /dev/null +++ b/lib/src/core/http/request_dispatch_exception.dart @@ -0,0 +1,33 @@ +/// A request failure that is known to have happened before the HTTP client +/// forwarded the request to the network. +/// +/// Creation replay may safely retain and retry an operation only when the +/// boundary that prevented dispatch supplies this explicit proof. +enum RequestPreDispatchFailureKind { authentication, connectivity } + +abstract interface class RequestNotDispatchedException implements Exception { + RequestPreDispatchFailureKind get kind; + Object? get cause; + String get code; +} + +final class KnownUnsentRequestException + implements RequestNotDispatchedException { + const KnownUnsentRequestException({required this.kind, this.cause}); + + @override + final RequestPreDispatchFailureKind kind; + @override + final Object? cause; + + @override + String get code => switch (kind) { + RequestPreDispatchFailureKind.authentication => + 'authentication_failed_before_dispatch', + RequestPreDispatchFailureKind.connectivity => + 'connectivity_failed_before_dispatch', + }; + + @override + String toString() => 'KnownUnsentRequestException($code)'; +} diff --git a/lib/src/dav/http/dav_http_transport.dart b/lib/src/dav/http/dav_http_transport.dart index f3891502..07e116a3 100644 --- a/lib/src/dav/http/dav_http_transport.dart +++ b/lib/src/dav/http/dav_http_transport.dart @@ -14,10 +14,21 @@ enum DavRetryClass { safeRead, conditionalMutation, never } final class DavCancellationToken { bool _cancelled = false; + final Completer _cancelledSignal = Completer(); + final StreamController _cancellations = + StreamController.broadcast(sync: true); bool get isCancelled => _cancelled; - - void cancel() => _cancelled = true; + Future get whenCancelled => _cancelledSignal.future; + Stream get cancellations => _cancellations.stream; + + void cancel() { + if (_cancelled) return; + _cancelled = true; + _cancelledSignal.complete(); + _cancellations.add(null); + unawaited(_cancellations.close()); + } void throwIfCancelled({String? correlationId}) { if (_cancelled) { @@ -208,29 +219,32 @@ final class DavHttpTransport { DavCancellationToken? cancellationToken, }) { final token = cancellationToken ?? DavCancellationToken(); - return _withConcurrencyLimit(request, () { - return _sendWithRetry(request, credential, token).timeout( - _limits.operationTimeout, - onTimeout: () => throw DavException( - kind: DavErrorKind.timeout, - code: 'DavOperationTimeout', - safeMessage: 'The DAV operation timed out.', - correlationId: request.correlationId, - ), + return _withConcurrencyLimit(request, token, () async { + final context = _DavOperationContext( + externalToken: token, + timeout: _limits.operationTimeout, + correlationId: request.correlationId, ); + try { + return await _sendWithRetry(request, credential, context); + } finally { + context.dispose(); + } }); } Future _withConcurrencyLimit( DavRequest request, + DavCancellationToken token, Future Function() action, ) async { final account = _accountSemaphores.putIfAbsent( request.accountId, () => _AsyncSemaphore(_limits.maximumConcurrentPerAccount), ); - await account.acquire(); + await account.acquire(token, correlationId: request.correlationId); _AsyncSemaphore? collection; + var collectionAcquired = false; try { final collectionId = request.collectionId; if (collectionId != null) { @@ -238,11 +252,13 @@ final class DavHttpTransport { '${request.accountId}|$collectionId', () => _AsyncSemaphore(_limits.maximumConcurrentPerCollection), ); - await collection.acquire(); + await collection.acquire(token, correlationId: request.correlationId); + collectionAcquired = true; } + token.throwIfCancelled(correlationId: request.correlationId); return await action(); } finally { - collection?.release(); + if (collectionAcquired) collection!.release(); account.release(); } } @@ -250,19 +266,19 @@ final class DavHttpTransport { Future _sendWithRetry( DavRequest request, DavBasicCredential credential, - DavCancellationToken token, + _DavOperationContext context, ) async { final attempts = request.retryClass == DavRetryClass.safeRead ? _limits.maximumReadAttempts : 1; Object? lastError; for (var attempt = 1; attempt <= attempts; attempt += 1) { - token.throwIfCancelled(correlationId: request.correlationId); + context.throwIfCancelled(); try { final response = await _sendFollowingRedirects( request, credential, - token, + context, ); if (!_isRetryableStatus(response.statusCode) || attempt == attempts) { return response; @@ -270,24 +286,28 @@ final class DavHttpTransport { final honorsRetryAfter = response.statusCode == HttpStatus.tooManyRequests || response.statusCode == HttpStatus.serviceUnavailable; - await _delay( - _retryDelay( - honorsRetryAfter ? response.headers['retry-after'] : null, - attempt, + await context.waitFor( + _delay( + _retryDelay( + honorsRetryAfter ? response.headers['retry-after'] : null, + attempt, + ), ), ); } on DavException catch (error) { lastError = error; + if (context.isCancelled) context.throwIfCancelled(); if (attempt == attempts || !_isRetryableException(error)) { rethrow; } - await _delay(_retryDelay(null, attempt)); + await context.waitFor(_delay(_retryDelay(null, attempt))); } on Object catch (error) { lastError = error; + if (context.isCancelled) context.throwIfCancelled(); if (attempt == attempts) { throw _networkException(error, request.correlationId); } - await _delay(_retryDelay(null, attempt)); + await context.waitFor(_delay(_retryDelay(null, attempt))); } } throw _networkException(lastError, request.correlationId); @@ -296,7 +316,7 @@ final class DavHttpTransport { Future _sendFollowingRedirects( DavRequest request, DavBasicCredential credential, - DavCancellationToken token, + _DavOperationContext context, ) async { var currentUri = request.uri; var method = request.method.toUpperCase(); @@ -308,7 +328,7 @@ final class DavHttpTransport { redirectCount <= _limits.maximumRedirects; redirectCount += 1 ) { - token.throwIfCancelled(correlationId: request.correlationId); + context.throwIfCancelled(); if (!visited.add(currentUri.toString())) { throw DavException( kind: DavErrorKind.redirectLoop, @@ -329,11 +349,13 @@ final class DavHttpTransport { ); } - final outbound = http.Request(method, currentUri) - ..followRedirects = false - ..headers.addAll(request.headers) - ..headers['authorization'] = credential.authorizationValue - ..headers['x-busymax-correlation-id'] = request.correlationId; + final abort = Completer(); + final outbound = + http.AbortableRequest(method, currentUri, abortTrigger: abort.future) + ..followRedirects = false + ..headers.addAll(request.headers) + ..headers['authorization'] = credential.authorizationValue + ..headers['x-busymax-correlation-id'] = request.correlationId; if (_profile.provider == BusyProvider.nextcloud && _serverFeatures.contains('nc-calendar-webcal-cache')) { outbound.headers['X-NC-CalDAV-Webcal-Caching'] = 'On'; @@ -342,21 +364,16 @@ final class DavHttpTransport { outbound.bodyBytes = body; } - final streamed = await _client - .send(outbound) - .timeout( - _limits.connectTimeout, - onTimeout: () => throw DavException( - kind: DavErrorKind.timeout, - code: 'DavConnectTimeout', - safeMessage: - 'The DAV server did not accept a connection in time.', - correlationId: request.correlationId, - ), - ); + final streamed = await _sendAttempt( + outbound, + abort, + context, + request.correlationId, + ); final responseBytes = await _readBoundedBody( streamed, - token, + abort, + context, request.correlationId, ); final headers = { @@ -410,30 +427,136 @@ final class DavHttpTransport { Future _readBoundedBody( http.StreamedResponse response, - DavCancellationToken token, + Completer abort, + _DavOperationContext context, String correlationId, ) async { final builder = BytesBuilder(copy: false); var length = 0; - await for (final chunk in response.stream.timeout( - _limits.responseTimeout, - )) { - token.throwIfCancelled(correlationId: correlationId); - length += chunk.length; - if (length > _limits.maximumResponseBytes) { - throw DavException( - kind: DavErrorKind.responseTooLarge, - code: 'DavResponseTooLarge', - safeMessage: 'The DAV response exceeded the configured size limit.', - statusCode: response.statusCode, - correlationId: correlationId, - ); + final completed = Completer(); + Timer? responseTimer; + StreamSubscription>? subscription; + + void abortRequest() { + if (!abort.isCompleted) abort.complete(); + } + + void completeError(Object error, [StackTrace? stackTrace]) { + if (completed.isCompleted) return; + abortRequest(); + if (stackTrace == null) { + completed.completeError(error); + } else { + completed.completeError(error, stackTrace); } - builder.add(chunk); + unawaited(subscription?.cancel()); + } + + void armResponseTimer() { + responseTimer?.cancel(); + responseTimer = Timer(_limits.responseTimeout, () { + completeError( + DavException( + kind: DavErrorKind.timeout, + code: 'DavResponseTimeout', + safeMessage: 'The DAV response body timed out.', + statusCode: response.statusCode, + correlationId: correlationId, + ), + ); + }); + } + + armResponseTimer(); + subscription = response.stream.listen( + (chunk) { + if (completed.isCompleted) return; + armResponseTimer(); + length += chunk.length; + if (length > _limits.maximumResponseBytes) { + completeError( + DavException( + kind: DavErrorKind.responseTooLarge, + code: 'DavResponseTooLarge', + safeMessage: + 'The DAV response exceeded the configured size limit.', + statusCode: response.statusCode, + correlationId: correlationId, + ), + ); + return; + } + builder.add(chunk); + }, + onError: (Object error, StackTrace stackTrace) { + if (context.isCancelled) { + completeError(context.cancellationException, stackTrace); + } else { + completeError(error, stackTrace); + } + }, + onDone: () { + if (!completed.isCompleted) completed.complete(); + }, + cancelOnError: true, + ); + unawaited( + context.whenCancelled.then((_) { + completeError(context.cancellationException); + }), + ); + try { + await completed.future; + } finally { + responseTimer?.cancel(); + await subscription.cancel(); } return builder.takeBytes(); } + Future _sendAttempt( + http.AbortableRequest request, + Completer abort, + _DavOperationContext context, + String correlationId, + ) async { + final connectTimeout = Completer(); + final timer = Timer(_limits.connectTimeout, () { + if (!connectTimeout.isCompleted) { + connectTimeout.completeError( + DavException( + kind: DavErrorKind.timeout, + code: 'DavConnectTimeout', + safeMessage: 'The DAV server did not accept a connection in time.', + correlationId: correlationId, + ), + ); + } + }); + final sendFuture = _client.send(request); + final cancellationFuture = context.whenCancelled + .then( + (_) => throw context.cancellationException, + ); + try { + return await Future.any([ + sendFuture, + connectTimeout.future, + cancellationFuture, + ]); + } on Object { + if (!abort.isCompleted) abort.complete(); + try { + await sendFuture; + } on Object { + // The original failure below retains the timeout/cancellation reason. + } + rethrow; + } finally { + timer.cancel(); + } + } + bool _isRetryableStatus(int statusCode) => statusCode == HttpStatus.tooManyRequests || statusCode == HttpStatus.serviceUnavailable || @@ -500,22 +623,43 @@ final class _AsyncSemaphore { final int maximum; int _available; - final _waiters = >[]; + final _waiters = <_SemaphoreWaiter>[]; - Future acquire() { + Future acquire( + DavCancellationToken token, { + required String correlationId, + }) { + token.throwIfCancelled(correlationId: correlationId); if (_available > 0) { _available -= 1; return Future.value(); } - final completer = Completer(); - _waiters.add(completer); - return completer.future; + final waiter = _SemaphoreWaiter(); + _waiters.add(waiter); + void cancelWaiter() { + if (!waiter.isQueued) return; + if (!_waiters.remove(waiter)) return; + waiter.cancel( + DavException( + kind: DavErrorKind.cancelled, + code: 'DavOperationCancelled', + safeMessage: 'The DAV operation was cancelled.', + correlationId: correlationId, + ), + ); + } + + waiter.cancellationSubscription = token.cancellations.listen( + (_) => cancelWaiter(), + ); + if (token.isCancelled) cancelWaiter(); + return waiter.future; } void release() { - if (_waiters.isNotEmpty) { - _waiters.removeAt(0).complete(); - return; + while (_waiters.isNotEmpty) { + final waiter = _waiters.removeAt(0); + if (waiter.grant()) return; } if (_available >= maximum) { throw StateError('DAV semaphore released more often than acquired.'); @@ -523,3 +667,102 @@ final class _AsyncSemaphore { _available += 1; } } + +final class _SemaphoreWaiter { + final Completer _completer = Completer(); + var _queued = true; + StreamSubscription? cancellationSubscription; + + bool get isQueued => _queued; + Future get future => _completer.future; + + bool grant() { + if (!_queued) return false; + _queued = false; + unawaited(cancellationSubscription?.cancel()); + _completer.complete(); + return true; + } + + void cancel(DavException error) { + if (!_queued) return; + _queued = false; + unawaited(cancellationSubscription?.cancel()); + _completer.completeError(error); + } +} + +final class _DavOperationContext { + _DavOperationContext({ + required DavCancellationToken externalToken, + required Duration timeout, + required this.correlationId, + }) { + _timer = Timer(timeout, _timeout); + _externalCancellationSubscription = externalToken.cancellations.listen( + (_) => _cancel(), + ); + if (externalToken.isCancelled) _cancel(); + } + + final String correlationId; + final Completer _cancelled = Completer(); + late Timer _timer; + StreamSubscription? _externalCancellationSubscription; + DavException? _exception; + var _disposed = false; + + bool get isCancelled => _exception != null; + Future get whenCancelled => _cancelled.future; + DavException get cancellationException => + _exception ?? + DavException( + kind: DavErrorKind.cancelled, + code: 'DavOperationCancelled', + safeMessage: 'The DAV operation was cancelled.', + correlationId: correlationId, + ); + + void _timeout() { + if (_disposed || _exception != null) return; + _exception = DavException( + kind: DavErrorKind.timeout, + code: 'DavOperationTimeout', + safeMessage: 'The DAV operation timed out.', + correlationId: correlationId, + ); + _cancelled.complete(); + } + + void _cancel() { + if (_disposed || _exception != null) return; + _exception = DavException( + kind: DavErrorKind.cancelled, + code: 'DavOperationCancelled', + safeMessage: 'The DAV operation was cancelled.', + correlationId: correlationId, + ); + _cancelled.complete(); + } + + void throwIfCancelled() { + final error = _exception; + if (error != null) throw error; + } + + Future waitFor(Future future) { + throwIfCancelled(); + return Future.any([ + future, + whenCancelled.then((_) => throw cancellationException), + ]); + } + + void dispose() { + if (_disposed) return; + _disposed = true; + _timer.cancel(); + unawaited(_externalCancellationSubscription?.cancel()); + _externalCancellationSubscription = null; + } +} diff --git a/lib/src/features/connectivity/network_connectivity_service.dart b/lib/src/features/connectivity/network_connectivity_service.dart index e2fc010b..74023b1f 100644 --- a/lib/src/features/connectivity/network_connectivity_service.dart +++ b/lib/src/features/connectivity/network_connectivity_service.dart @@ -4,13 +4,25 @@ import 'package:connectivity_plus/connectivity_plus.dart'; import 'package:http/http.dart' as http; import 'package:logging/logging.dart'; +import '../../core/http/request_dispatch_exception.dart'; import '../../core/logging/redacting_logger.dart'; enum NetworkAvailability { unknown, online, offline } -final class NetworkUnavailableException implements Exception { +final class NetworkUnavailableException + implements RequestNotDispatchedException { const NetworkUnavailableException(); + @override + RequestPreDispatchFailureKind get kind => + RequestPreDispatchFailureKind.connectivity; + + @override + Object? get cause => null; + + @override + String get code => 'connectivity_failed_before_dispatch'; + @override String toString() => 'NetworkUnavailableException'; } @@ -206,7 +218,20 @@ final class ConnectivityAwareHttpClient extends http.BaseClient { @override Future send(http.BaseRequest request) async { - await _requireNetwork(); + try { + await _requireNetwork(); + } on Object catch (error, stackTrace) { + if (error is RequestNotDispatchedException) { + Error.throwWithStackTrace(error, stackTrace); + } + Error.throwWithStackTrace( + KnownUnsentRequestException( + kind: RequestPreDispatchFailureKind.connectivity, + cause: error, + ), + stackTrace, + ); + } return _inner.send(request); } diff --git a/lib/src/features/schedule/presentation/schedule_workspace.dart b/lib/src/features/schedule/presentation/schedule_workspace.dart index 6d446474..aa8dbf2e 100644 --- a/lib/src/features/schedule/presentation/schedule_workspace.dart +++ b/lib/src/features/schedule/presentation/schedule_workspace.dart @@ -152,6 +152,7 @@ class ScheduleWorkspace extends ConsumerStatefulWidget { const ScheduleWorkspace({ super.key, this.initialScope = ScheduleScope.all, + this.initialDate, this.initialTaskAccountId, this.initialTaskListId, this.initialTaskId, @@ -159,6 +160,8 @@ class ScheduleWorkspace extends ConsumerStatefulWidget { }); final ScheduleScope initialScope; + @visibleForTesting + final DateTime? initialDate; final String? initialTaskAccountId; final String? initialTaskListId; final String? initialTaskId; @@ -228,6 +231,9 @@ class _ScheduleWorkspaceState extends ConsumerState { @override void initState() { super.initState(); + if (widget.initialDate != null) { + _selectedDate = DateUtils.dateOnly(widget.initialDate!); + } _scope = widget.initialScope; _applyInitialScope(); _headerBarSession = ref.read(linuxHeaderBarServiceProvider).claimSession(); @@ -1436,14 +1442,11 @@ class _ScheduleWorkspaceState extends ConsumerState { } setState(() { _selectedDate = switch (_mode) { - ScheduleViewMode.day => _selectedDate.subtract(const Duration(days: 1)), - ScheduleViewMode.week => _selectedDate.subtract( - const Duration(days: 7), - ), - ScheduleViewMode.month => DateTime( - _selectedDate.year, - _selectedDate.month - 1, - _selectedDate.day, + ScheduleViewMode.day => DateUtils.addDaysToDate(_selectedDate, -1), + ScheduleViewMode.week => DateUtils.addDaysToDate(_selectedDate, -7), + ScheduleViewMode.month => DateUtils.addMonthsToMonthDate( + _selectedDate, + -1, ), ScheduleViewMode.year => DateTime( _selectedDate.year - 1, @@ -1465,12 +1468,11 @@ class _ScheduleWorkspaceState extends ConsumerState { } setState(() { _selectedDate = switch (_mode) { - ScheduleViewMode.day => _selectedDate.add(const Duration(days: 1)), - ScheduleViewMode.week => _selectedDate.add(const Duration(days: 7)), - ScheduleViewMode.month => DateTime( - _selectedDate.year, - _selectedDate.month + 1, - _selectedDate.day, + ScheduleViewMode.day => DateUtils.addDaysToDate(_selectedDate, 1), + ScheduleViewMode.week => DateUtils.addDaysToDate(_selectedDate, 7), + ScheduleViewMode.month => DateUtils.addMonthsToMonthDate( + _selectedDate, + 1, ), ScheduleViewMode.year => DateTime( _selectedDate.year + 1, diff --git a/lib/src/features/sync/pending_op_resolution_service.dart b/lib/src/features/sync/pending_op_resolution_service.dart index c4ad438d..a3231f2a 100644 --- a/lib/src/features/sync/pending_op_resolution_service.dart +++ b/lib/src/features/sync/pending_op_resolution_service.dart @@ -1,5 +1,7 @@ import 'dart:convert'; +import 'package:drift/drift.dart'; + import '../../calendar_providers/cloud_calendar_client.dart'; import '../../dav/mutation/dav_pending_operation_selection.dart'; import '../../dav/mutation/dav_pending_operations.dart'; @@ -10,6 +12,9 @@ import '../task_lists/data/task_lists_repository.dart'; import '../tasks/data/tasks_repository.dart'; import '../tasks/domain/task_remote_client.dart'; import '../tasks/domain/task_remote_error.dart'; +import '../tasks/domain/task_checklist_item.dart'; +import '../tasks/domain/task_remote_models.dart'; +import 'pending_ops_replay_coordinator.dart'; class PendingOpResolutionService { PendingOpResolutionService({ @@ -97,13 +102,165 @@ class PendingOpResolutionService { return; } - final syncAfterDiscard = await _refreshOrRemoveLocalState(op); - await _database.pendingOpsDao.deleteOp(op.id); + final isTaskOperation = + op.entityType == 'task' || + op.entityType == 'task_list' || + op.entityType == 'task_checklist_item'; + final syncAfterDiscard = isTaskOperation + ? await serializePendingOpsReplay( + database: _database, + accountId: _accountId, + replay: () => _discardTaskOperation(op.id), + ) + : await _discardUncoordinatedOperation(op); if (syncAfterDiscard) { + // Synchronization uses the same non-reentrant account coordinator. The + // callback must run only after the discard critical section is released. await _syncAfterResolution(op); } } + Future _discardUncoordinatedOperation(PendingOp op) async { + final syncAfterDiscard = await _refreshOrRemoveLocalState(op); + await _database.pendingOpsDao.deleteOp(op.id); + return syncAfterDiscard; + } + + Future _discardTaskOperation(String opId) async { + final current = await _database.pendingOpsDao.getOp(opId); + if (current == null) return false; + _requireOwnedOperation(current); + if (current.operation == 'create_task_checklist_item') { + return _discardUncertainChecklistCreation(current); + } + final syncAfterDiscard = await _refreshOrRemoveLocalState(current); + await _database.pendingOpsDao.deleteOp(current.id); + return syncAfterDiscard; + } + + Future _discardUncertainChecklistCreation(PendingOp snapshot) async { + final taskListId = snapshot.taskListId; + final parentTaskId = snapshot.taskId; + final temporaryItemId = _checklistItemId(snapshot); + if (taskListId == null || + parentTaskId == null || + temporaryItemId == null || + temporaryItemId.isEmpty) { + throw StateError('The checklist recovery operation is incomplete.'); + } + + final client = _requiredChecklistClient; + final serverItems = []; + var parentMissing = false; + String? pageToken; + try { + do { + final page = await client.listChecklistItemsPage( + taskListId: taskListId, + taskId: parentTaskId, + pageToken: pageToken, + ); + serverItems.addAll(page.items); + pageToken = page.nextPageToken; + } while (pageToken != null && pageToken.isNotEmpty); + } on TaskRemoteError catch (error) { + if (error.statusCode != 404) rethrow; + parentMissing = true; + } + + var discarded = false; + await _database.transaction(() async { + final current = await _database.pendingOpsDao.getOp(snapshot.id); + if (current == null) return; + if (current.accountId != _accountId || + current.operation != 'create_task_checklist_item' || + current.taskListId != taskListId || + current.taskId != parentTaskId || + _checklistItemId(current) != temporaryItemId) { + throw StateError('The checklist recovery operation changed.'); + } + + final operations = + await (_database.select(_database.pendingOps) + ..where( + (row) => + row.accountId.equals(_accountId) & + row.entityType.equals('task_checklist_item') & + row.taskListId.equals(taskListId) & + row.taskId.equals(parentTaskId), + ) + ..orderBy([ + (row) => OrderingTerm.asc(row.createdAtUtc), + (row) => OrderingTerm.asc(row.updatedAtUtc), + (row) => OrderingTerm.asc(row.id), + ])) + .get(); + final discardedIds = {current.id}; + var expanded = true; + while (expanded) { + expanded = false; + for (final operation in operations) { + if (discardedIds.contains(operation.id)) continue; + if (discardedIds.contains(operation.dependsOnOpId) || + _checklistItemId(operation) == temporaryItemId) { + discardedIds.add(operation.id); + expanded = true; + } + } + } + final remaining = [ + for (final operation in operations) + if (!discardedIds.contains(operation.id)) operation, + ]; + + if (parentMissing) { + await _database.tasksDao.deleteTask( + _accountId, + taskListId, + parentTaskId, + ); + } else { + final task = + await (_database.select(_database.tasks)..where( + (row) => + row.accountId.equals(_accountId) & + row.taskListId.equals(taskListId) & + row.id.equals(parentTaskId), + )) + .getSingleOrNull(); + if (task != null) { + final localItems = decodeTaskChecklistItems( + task.microsoftChecklistItemsJson, + ).where((item) => item.id != temporaryItemId); + final merged = mergeTaskChecklistProjection( + serverItems: serverItems, + localItems: localItems, + pendingOperations: remaining, + ); + await (_database.update(_database.tasks)..where( + (row) => + row.accountId.equals(_accountId) & + row.taskListId.equals(taskListId) & + row.id.equals(parentTaskId), + )) + .write( + TasksCompanion( + microsoftChecklistItemsJson: Value( + encodeTaskChecklistItems(merged), + ), + updatedLocalAtUtc: Value(_now()), + ), + ); + } + } + await (_database.delete( + _database.pendingOps, + )..where((row) => row.id.isIn(discardedIds))).go(); + discarded = true; + }); + return discarded; + } + Future _refreshOrRemoveLocalState(PendingOp op) async { if (op.entityType == 'calendar') { final repository = CalendarRepository(database: _database, now: _nowUtc); @@ -174,6 +331,24 @@ class PendingOpResolutionService { (throw StateError('Task operation recovery is unavailable.')); } + TaskChecklistRemoteClient get _requiredChecklistClient { + final client = _requiredTaskClient; + if (client is TaskChecklistRemoteClient) { + return client as TaskChecklistRemoteClient; + } + throw StateError('Checklist operation recovery is unavailable.'); + } + + String? _checklistItemId(PendingOp op) { + try { + final request = (jsonDecode(op.requestJson) as Map) + .cast(); + return request['checklistItemId']?.toString() ?? op.localTempId; + } on Object { + return op.localTempId; + } + } + Future _refreshOrRemoveMovedTask(PendingOp op) async { final sourceTaskListId = op.taskListId!; final taskId = op.taskId!; diff --git a/lib/src/features/sync/pending_ops_replayer.dart b/lib/src/features/sync/pending_ops_replayer.dart index 69e924fa..be8fdea9 100644 --- a/lib/src/features/sync/pending_ops_replayer.dart +++ b/lib/src/features/sync/pending_ops_replayer.dart @@ -3,6 +3,7 @@ import 'dart:math'; import 'package:drift/drift.dart'; +import '../../core/http/request_dispatch_exception.dart'; import '../../db/app_database.dart'; import '../tasks/domain/task_remote_client.dart'; import '../tasks/domain/task_remote_error.dart'; @@ -97,6 +98,12 @@ class PendingOpsReplayer { await _replay(op); await _database.pendingOpsDao.deleteOp(op.id); applied += 1; + } on RequestNotDispatchedException catch (error) { + await _scheduleRetry( + op, + error.code, + 'The request was not sent and can be retried safely.', + ); } on TaskRemoteError catch (error) { if (_isSuccessfulMissingDelete(op, error)) { await _applyDeleteSideEffect(op); @@ -193,9 +200,36 @@ class PendingOpsReplayer { } await _database.transaction(() async { + final temporaryList = + await (_database.select(_database.taskLists)..where( + (row) => + row.accountId.equals(_accountId) & row.id.equals(tempId), + )) + .getSingleOrNull(); + final hasDependent = await _rebaseDependentTaskListMutations(op, dto); await _database.taskListsDao.upsertTaskList( taskListFromDto(_accountId, dto, _now()), ); + if (temporaryList != null) { + await (_database.update(_database.taskLists)..where( + (row) => row.accountId.equals(_accountId) & row.id.equals(dto.id), + )) + .write( + TaskListsCompanion( + remindersEnabled: Value(temporaryList.remindersEnabled), + title: hasDependent + ? Value(temporaryList.title) + : const Value.absent(), + localDirty: hasDependent + ? const Value(true) + : const Value.absent(), + pendingDelete: hasDependent + ? Value(temporaryList.pendingDelete) + : const Value.absent(), + updatedLocalAtUtc: Value(_now()), + ), + ); + } await _database.customStatement( 'UPDATE tasks SET task_list_id = ? WHERE account_id = ? ' 'AND task_list_id = ?', @@ -217,9 +251,7 @@ class PendingOpsReplayer { op.taskListId!, TaskListPatch(_request(op)), ); - await _database.taskListsDao.upsertTaskList( - taskListFromDto(_accountId, dto, _now()), - ); + await _applyTaskListEditResult(op, dto); } Future _updateTaskList(PendingOp op) async { @@ -228,9 +260,83 @@ class PendingOpsReplayer { op.taskListId!, TaskListPut(_request(op)), ); - await _database.taskListsDao.upsertTaskList( - taskListFromDto(_accountId, dto, _now()), + await _applyTaskListEditResult(op, dto); + } + + Future _applyTaskListEditResult( + PendingOp op, + TaskListDto serverList, + ) async { + await _database.transaction(() async { + final local = + await (_database.select(_database.taskLists)..where( + (row) => + row.accountId.equals(_accountId) & + row.id.equals(op.taskListId!), + )) + .getSingleOrNull(); + final hasDependent = await _rebaseDependentTaskListMutations( + op, + serverList, + ); + if (hasDependent) return; + await _database.taskListsDao.upsertTaskList( + taskListFromDto(_accountId, serverList, _now()), + ); + if (local != null) { + await (_database.update(_database.taskLists)..where( + (row) => + row.accountId.equals(_accountId) & + row.id.equals(serverList.id), + )) + .write( + TaskListsCompanion( + remindersEnabled: Value(local.remindersEnabled), + ), + ); + } + }); + } + + Future _rebaseDependentTaskListMutations( + PendingOp completedOp, + TaskListDto serverList, + ) async { + final dependents = + await (_database.select(_database.pendingOps)..where( + (row) => + row.accountId.equals(_accountId) & + row.dependsOnOpId.equals(completedOp.id), + )) + .get(); + if (dependents.isEmpty) return false; + + final acknowledged = _normalizeTaskListConflictSnapshot( + _request(completedOp), ); + final serverSnapshot = _normalizeTaskListConflictSnapshot( + serverList.rawJson, + ); + for (final dependent in dependents) { + final baseline = _normalizeTaskListConflictSnapshot( + _jsonObject(dependent.baselineRawJson ?? '{}'), + ); + for (final field in acknowledged.keys) { + baseline[field] = serverSnapshot[field]; + } + await (_database.update( + _database.pendingOps, + )..where((row) => row.id.equals(dependent.id))).write( + PendingOpsCompanion( + baselineRawJson: Value(jsonEncode(baseline)), + baselineUpdatedUtc: serverList.updated == null + ? const Value.absent() + : Value(serverList.updated!.toUtc().toIso8601String()), + updatedAtUtc: Value(_now()), + ), + ); + } + return true; } Future _deleteTaskList(PendingOp op) async { @@ -287,7 +393,7 @@ class PendingOpsReplayer { Future _applyTaskEditResult(PendingOp op, TaskDto serverTask) async { await _database.transaction(() async { - final hasDependent = await _rebaseDependentTaskEdits(op, serverTask); + final hasDependent = await _rebaseDependentTaskMutations(op, serverTask); if (hasDependent) { return; } @@ -297,7 +403,7 @@ class PendingOpsReplayer { }); } - Future _rebaseDependentTaskEdits( + Future _rebaseDependentTaskMutations( PendingOp completedOp, TaskDto serverTask, ) async { @@ -361,6 +467,13 @@ class PendingOpsReplayer { destinationTaskListId: destinationTaskListId, ); await _database.transaction(() async { + final hasDependent = await _rebaseDependentTaskMutations(op, dto); + if (hasDependent) { + // The repository has already moved the visible projection to the tail + // of the local chain. Applying this intermediate response would move + // it backwards and erase later local edits. + return; + } await _database.tasksDao.upsertTask( taskFromDto(_accountId, targetTaskListId, dto, _now()), ); @@ -662,6 +775,8 @@ class PendingOpsReplayer { final unblock = op.id != completedCreateOpId && referenceChanged && + op.state != 'recovery_required' && + op.lastErrorCode != 'creation_outcome_unknown' && op.nextAttemptAtUtc?.startsWith('9999-12-31') == true; if (rewrittenJson == op.requestJson && !unblock) { continue; diff --git a/lib/src/features/task_lists/data/task_lists_repository.dart b/lib/src/features/task_lists/data/task_lists_repository.dart index 03d09dc4..f0ce6976 100644 --- a/lib/src/features/task_lists/data/task_lists_repository.dart +++ b/lib/src/features/task_lists/data/task_lists_repository.dart @@ -189,22 +189,25 @@ class TaskListsRepository { ); return; } - await _updateLocalList( - id, - TaskListsCompanion( - title: Value(title), - localDirty: const Value(true), - updatedLocalAtUtc: Value(now), - ), - ); - await _enqueue( - operation: 'patch_task_list', - taskListId: id, - request: {'title': title}, - baselineUpdatedUtc: baseline?.updatedUtc, - baselineRawJson: baseline?.rawJson, - createdAtUtc: now, - ); + await _database.transaction(() async { + final current = await _baselineRow(id); + await _updateLocalList( + id, + TaskListsCompanion( + title: Value(title), + localDirty: const Value(true), + updatedLocalAtUtc: Value(now), + ), + ); + await _enqueue( + operation: 'patch_task_list', + taskListId: id, + request: {'title': title}, + baselineUpdatedUtc: current?.updatedUtc, + baselineRawJson: current?.rawJson, + createdAtUtc: now, + ); + }); _onMutationQueued?.call(); } @@ -224,24 +227,27 @@ class TaskListsRepository { ); return; } - if (title != null) { - await _updateLocalList( - id, - TaskListsCompanion( - title: Value(title), - localDirty: const Value(true), - updatedLocalAtUtc: Value(now), - ), + await _database.transaction(() async { + final current = await _baselineRow(id); + if (title != null) { + await _updateLocalList( + id, + TaskListsCompanion( + title: Value(title), + localDirty: const Value(true), + updatedLocalAtUtc: Value(now), + ), + ); + } + await _enqueue( + operation: 'update_task_list', + taskListId: id, + request: replacement.toJson(), + baselineUpdatedUtc: current?.updatedUtc, + baselineRawJson: current?.rawJson, + createdAtUtc: now, ); - } - await _enqueue( - operation: 'update_task_list', - taskListId: id, - request: replacement.toJson(), - baselineUpdatedUtc: baseline?.updatedUtc, - baselineRawJson: baseline?.rawJson, - createdAtUtc: now, - ); + }); _onMutationQueued?.call(); } @@ -254,22 +260,25 @@ class TaskListsRepository { ); return; } - await _updateLocalList( - id, - TaskListsCompanion( - pendingDelete: const Value(true), - localDirty: const Value(true), - updatedLocalAtUtc: Value(now), - ), - ); - await _enqueue( - operation: 'delete_task_list', - taskListId: id, - request: const {}, - baselineUpdatedUtc: baseline?.updatedUtc, - baselineRawJson: baseline?.rawJson, - createdAtUtc: now, - ); + await _database.transaction(() async { + final current = await _baselineRow(id); + await _updateLocalList( + id, + TaskListsCompanion( + pendingDelete: const Value(true), + localDirty: const Value(true), + updatedLocalAtUtc: Value(now), + ), + ); + await _enqueue( + operation: 'delete_task_list', + taskListId: id, + request: const {}, + baselineUpdatedUtc: current?.updatedUtc, + baselineRawJson: current?.rawJson, + createdAtUtc: now, + ); + }); await NotificationScheduleService( database: _database, nowUtc: _nowUtc, @@ -324,8 +333,11 @@ class TaskListsRepository { String? localTempId, String? baselineUpdatedUtc, String? baselineRawJson, - }) { - return _database.pendingOpsDao.enqueue( + }) async { + final predecessor = taskListId == null + ? null + : await _latestPendingTaskListOperation(taskListId); + await _database.pendingOpsDao.enqueue( PendingOpsCompanion.insert( id: _uuid.v4(), accountId: _accountId, @@ -333,6 +345,7 @@ class TaskListsRepository { operation: operation, taskListId: Value(taskListId), localTempId: Value(localTempId), + dependsOnOpId: Value(predecessor?.id), requestJson: jsonEncode(request), baselineUpdatedUtc: Value(baselineUpdatedUtc), baselineRawJson: Value(baselineRawJson), @@ -342,6 +355,33 @@ class TaskListsRepository { ); } + Future _latestPendingTaskListOperation(String taskListId) async { + final operations = + await (_database.select(_database.pendingOps) + ..where( + (row) => + row.accountId.equals(_accountId) & + row.entityType.equals('task_list') & + row.operation.isIn(_taskListMutationOperations) & + (row.taskListId.equals(taskListId) | + row.localTempId.equals(taskListId)), + ) + ..orderBy([ + (row) => OrderingTerm.desc(row.createdAtUtc), + (row) => OrderingTerm.desc(row.updatedAtUtc), + (row) => OrderingTerm.desc(row.id), + ])) + .get(); + final predecessorIds = { + for (final operation in operations) + if (operation.dependsOnOpId != null) operation.dependsOnOpId!, + }; + for (final operation in operations) { + if (!predecessorIds.contains(operation.id)) return operation; + } + return operations.firstOrNull; + } + Future _baselineRow(String id) { return (_database.select( _database.taskLists, @@ -372,6 +412,13 @@ class TaskListsRepository { String _now() => _nowUtc().toIso8601String(); } +const _taskListMutationOperations = { + 'create_task_list', + 'patch_task_list', + 'update_task_list', + 'delete_task_list', +}; + TaskListsCompanion taskListFromDto( String accountId, TaskListDto dto, diff --git a/lib/src/features/tasks/data/tasks_repository.dart b/lib/src/features/tasks/data/tasks_repository.dart index 3272cf68..f7d5b3f1 100644 --- a/lib/src/features/tasks/data/tasks_repository.dart +++ b/lib/src/features/tasks/data/tasks_repository.dart @@ -900,35 +900,37 @@ class TasksRepository { return; } final now = _now(); - final baseline = await _baselineRow(input.sourceTaskListId, input.taskId); - await _writeLocalTask( - input.sourceTaskListId, - input.taskId, - TasksCompanion( - taskListId: Value( - input.destinationTaskListId ?? input.sourceTaskListId, + await _database.transaction(() async { + final baseline = await _baselineRow(input.sourceTaskListId, input.taskId); + await _writeLocalTask( + input.sourceTaskListId, + input.taskId, + TasksCompanion( + taskListId: Value( + input.destinationTaskListId ?? input.sourceTaskListId, + ), + parent: Value(input.parentTaskId), + pendingMove: const Value(true), + localDirty: const Value(true), + updatedLocalAtUtc: Value(now), ), - parent: Value(input.parentTaskId), - pendingMove: const Value(true), - localDirty: const Value(true), - updatedLocalAtUtc: Value(now), - ), - ); - await _enqueue( - operation: 'move_task', - taskListId: input.sourceTaskListId, - taskId: input.taskId, - request: { - if (input.parentTaskId != null) 'parent': input.parentTaskId, - if (input.previousSiblingTaskId != null) - 'previous': input.previousSiblingTaskId, - if (input.destinationTaskListId != null) - 'destinationTasklist': input.destinationTaskListId, - }, - baselineUpdatedUtc: baseline?.updatedUtc, - baselineRawJson: baseline?.rawJson, - createdAtUtc: now, - ); + ); + await _enqueue( + operation: 'move_task', + taskListId: input.sourceTaskListId, + taskId: input.taskId, + request: { + if (input.parentTaskId != null) 'parent': input.parentTaskId, + if (input.previousSiblingTaskId != null) + 'previous': input.previousSiblingTaskId, + if (input.destinationTaskListId != null) + 'destinationTasklist': input.destinationTaskListId, + }, + baselineUpdatedUtc: baseline?.updatedUtc, + baselineRawJson: baseline?.rawJson, + createdAtUtc: now, + ); + }); _onMutationQueued?.call(); } @@ -2515,24 +2517,50 @@ class TasksRepository { taskId == null) { return null; } - final pendingCreate = await _pendingTaskCreate(taskId); - final pendingLocalId = pendingCreate?.localTempId ?? pendingCreate?.taskId; final query = _database.select(_database.pendingOps) ..where( (row) => row.accountId.equals(_accountId) & row.entityType.equals('task') & row.operation.isIn(_taskMutationPredecessorOperations) & - (pendingLocalId == null - ? row.taskListId.equals(taskListId) & row.taskId.equals(taskId) - : row.taskId.equals(pendingLocalId) | - row.localTempId.equals(pendingLocalId)), + (row.taskId.equals(taskId) | row.localTempId.equals(taskId)), ) ..orderBy([ (row) => OrderingTerm.desc(row.createdAtUtc), (row) => OrderingTerm.desc(row.updatedAtUtc), + (row) => OrderingTerm.desc(row.id), ]); - final operations = await query.get(); + final allOperations = await query.get(); + final relevantListIds = {taskListId}; + var expanded = true; + while (expanded) { + expanded = false; + for (final candidate in allOperations) { + if (candidate.operation != 'move_task' || + candidate.taskListId == null) { + continue; + } + final source = candidate.taskListId!; + final destination = _pendingRequestOrNull( + candidate, + )?['destinationTasklist']?.toString(); + if (!relevantListIds.contains(source) && + (destination == null || !relevantListIds.contains(destination))) { + continue; + } + if (relevantListIds.add(source)) expanded = true; + if (destination != null && relevantListIds.add(destination)) { + expanded = true; + } + } + } + final operations = allOperations + .where( + (candidate) => + candidate.taskListId == null || + relevantListIds.contains(candidate.taskListId), + ) + .toList(growable: false); final predecessorIds = { for (final operation in operations) if (operation.dependsOnOpId != null) operation.dependsOnOpId!, diff --git a/lib/src/google_tasks/api/google_tasks_api_client.dart b/lib/src/google_tasks/api/google_tasks_api_client.dart index a4972f26..09bdc888 100644 --- a/lib/src/google_tasks/api/google_tasks_api_client.dart +++ b/lib/src/google_tasks/api/google_tasks_api_client.dart @@ -2,6 +2,7 @@ import 'dart:convert'; import 'package:http/http.dart' as http; +import '../../core/http/request_dispatch_exception.dart'; import '../../features/tasks/domain/task_remote_client.dart'; import '../../features/tasks/domain/task_remote_models.dart'; import 'google_tasks_api_error.dart'; @@ -257,7 +258,17 @@ class GoogleTasksRestApiClient implements TaskRemoteClient { ); final refresh = _unauthorizedRefreshProvider; if (response.statusCode == 401 && refresh != null) { - await refresh(); + try { + await refresh(); + } on Object catch (error, stackTrace) { + Error.throwWithStackTrace( + KnownUnsentRequestException( + kind: RequestPreDispatchFailureKind.authentication, + cause: error, + ), + stackTrace, + ); + } response = await _sendOnce( method, uri, @@ -278,7 +289,18 @@ class GoogleTasksRestApiClient implements TaskRemoteClient { if (body != null) { headers['Content-Type'] = 'application/json; charset=utf-8'; } - final authorizationHeader = await _authorizationHeaderProvider?.call(); + String? authorizationHeader; + try { + authorizationHeader = await _authorizationHeaderProvider?.call(); + } on Object catch (error, stackTrace) { + Error.throwWithStackTrace( + KnownUnsentRequestException( + kind: RequestPreDispatchFailureKind.authentication, + cause: error, + ), + stackTrace, + ); + } if (authorizationHeader != null) { headers['Authorization'] = authorizationHeader; } diff --git a/lib/src/microsoft_todo/api/microsoft_todo_api_client.dart b/lib/src/microsoft_todo/api/microsoft_todo_api_client.dart index 40f150da..e7d637c5 100644 --- a/lib/src/microsoft_todo/api/microsoft_todo_api_client.dart +++ b/lib/src/microsoft_todo/api/microsoft_todo_api_client.dart @@ -2,6 +2,7 @@ import 'dart:convert'; import 'package:http/http.dart' as http; +import '../../core/http/request_dispatch_exception.dart'; import 'microsoft_todo_api_error.dart'; import 'microsoft_todo_api_models.dart'; import 'microsoft_todo_json.dart'; @@ -316,7 +317,17 @@ class MicrosoftTodoRestApiClient ); final refresh = _unauthorizedRefreshProvider; if (response.statusCode == 401 && refresh != null) { - await refresh(); + try { + await refresh(); + } on Object catch (error, stackTrace) { + Error.throwWithStackTrace( + KnownUnsentRequestException( + kind: RequestPreDispatchFailureKind.authentication, + cause: error, + ), + stackTrace, + ); + } response = await _sendOnce( method, uri, @@ -337,7 +348,18 @@ class MicrosoftTodoRestApiClient if (body != null) { headers['Content-Type'] = 'application/json; charset=utf-8'; } - final authorizationHeader = await _authorizationHeaderProvider?.call(); + String? authorizationHeader; + try { + authorizationHeader = await _authorizationHeaderProvider?.call(); + } on Object catch (error, stackTrace) { + Error.throwWithStackTrace( + KnownUnsentRequestException( + kind: RequestPreDispatchFailureKind.authentication, + cause: error, + ), + stackTrace, + ); + } if (authorizationHeader != null) { headers['Authorization'] = authorizationHeader; } diff --git a/test/dav/http/dav_http_transport_test.dart b/test/dav/http/dav_http_transport_test.dart index 3252ad84..bb942426 100644 --- a/test/dav/http/dav_http_transport_test.dart +++ b/test/dav/http/dav_http_transport_test.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:convert'; import 'dart:io'; import 'dart:math'; @@ -294,6 +295,267 @@ void main() { ), ); }); + + test( + 'operation timeout interrupts retry backoff without another request', + () async { + var calls = 0; + final delayGate = Completer(); + final transport = _nextcloudTransport( + MockClient((_) async { + calls += 1; + return http.Response('', 503, headers: {'retry-after': '1'}); + }), + limits: const DavTransportLimits( + operationTimeout: Duration(milliseconds: 25), + ), + delay: (_) => delayGate.future, + ); + + await expectLater( + transport.send( + _propfind( + Uri.parse('https://cloud.example.test/nextcloud/remote.php/dav'), + ), + credential: credential, + ), + throwsA( + isA().having( + (error) => error.code, + 'code', + 'DavOperationTimeout', + ), + ), + ); + delayGate.complete(); + await Future.delayed(Duration.zero); + expect(calls, 1); + }, + ); + + test('timeout awaiting headers aborts the active request', () async { + final client = _AbortObservingClient(hangFirstHeaders: true); + final transport = _nextcloudTransport( + client, + limits: const DavTransportLimits( + connectTimeout: Duration(seconds: 1), + operationTimeout: Duration(milliseconds: 25), + maximumReadAttempts: 1, + ), + ); + + await expectLater( + transport.send( + _propfind( + Uri.parse('https://cloud.example.test/nextcloud/remote.php/dav'), + ), + credential: credential, + ), + throwsA( + isA().having( + (error) => error.code, + 'code', + 'DavOperationTimeout', + ), + ), + ); + expect(client.calls, 1); + expect(client.aborts, 1); + }); + + test( + 'cancellation during body streaming aborts and stops consumption', + () async { + final client = _AbortObservingClient(streamBody: true); + final token = DavCancellationToken(); + final transport = _nextcloudTransport( + client, + limits: const DavTransportLimits( + operationTimeout: Duration(seconds: 1), + ), + ); + final request = transport.send( + _propfind( + Uri.parse('https://cloud.example.test/nextcloud/remote.php/dav'), + ), + credential: credential, + cancellationToken: token, + ); + await client.bodyStarted.future; + token.cancel(); + + await expectLater( + request, + throwsA( + isA().having( + (error) => error.kind, + 'kind', + DavErrorKind.cancelled, + ), + ), + ); + expect(client.aborts, 1); + expect(client.bodyCancelled, 1); + }, + ); + + test( + 'cancelled concurrency waiter never dispatches or leaks a permit', + () async { + final client = _AbortObservingClient(hangFirstHeaders: true); + final transport = _nextcloudTransport( + client, + limits: const DavTransportLimits( + operationTimeout: Duration(seconds: 1), + maximumReadAttempts: 1, + maximumConcurrentPerAccount: 1, + ), + ); + final firstToken = DavCancellationToken(); + final first = transport.send( + _propfind( + Uri.parse( + 'https://cloud.example.test/nextcloud/remote.php/dav/first', + ), + ), + credential: credential, + cancellationToken: firstToken, + ); + await client.firstStarted.future; + final waitingToken = DavCancellationToken(); + final waiting = transport.send( + _propfind( + Uri.parse( + 'https://cloud.example.test/nextcloud/remote.php/dav/waiting', + ), + ), + credential: credential, + cancellationToken: waitingToken, + ); + waitingToken.cancel(); + await expectLater(waiting, throwsA(isA())); + expect(client.calls, 1); + + firstToken.cancel(); + await expectLater(first, throwsA(isA())); + final third = await transport.send( + _propfind( + Uri.parse( + 'https://cloud.example.test/nextcloud/remote.php/dav/third', + ), + ), + credential: credential, + ); + expect(third.statusCode, 200); + expect(client.calls, 2); + }, + ); + + test( + 'operation timeout does not cancel a token shared with a sibling', + () async { + final client = _AbortObservingClient(hangFirstHeaders: true); + final token = DavCancellationToken(); + final transport = _nextcloudTransport( + client, + limits: const DavTransportLimits( + operationTimeout: Duration(milliseconds: 25), + maximumReadAttempts: 1, + ), + ); + + final timedOut = transport.send( + _propfind( + Uri.parse('https://cloud.example.test/nextcloud/remote.php/dav/slow'), + ), + credential: credential, + cancellationToken: token, + ); + final timedOutExpectation = expectLater( + timedOut, + throwsA( + isA().having( + (error) => error.code, + 'code', + 'DavOperationTimeout', + ), + ), + ); + await client.firstStarted.future; + final sibling = await transport.send( + _propfind( + Uri.parse( + 'https://cloud.example.test/nextcloud/remote.php/dav/sibling', + ), + ), + credential: credential, + cancellationToken: token, + ); + + expect(sibling.statusCode, 200); + await timedOutExpectation; + expect(token.isCancelled, isFalse); + expect(client.calls, 2); + expect(client.aborts, 1); + }, + ); +} + +final class _AbortObservingClient extends http.BaseClient { + _AbortObservingClient({ + this.hangFirstHeaders = false, + this.streamBody = false, + }); + + final bool hangFirstHeaders; + final bool streamBody; + final Completer firstStarted = Completer(); + final Completer bodyStarted = Completer(); + var calls = 0; + var aborts = 0; + var bodyCancelled = 0; + + @override + Future send(http.BaseRequest request) { + calls += 1; + if (!firstStarted.isCompleted) firstStarted.complete(); + final abortTrigger = request is http.Abortable + ? request.abortTrigger + : null; + if (hangFirstHeaders && calls == 1) { + final response = Completer(); + unawaited( + abortTrigger!.then((_) { + aborts += 1; + response.completeError(http.RequestAbortedException(request.url)); + }), + ); + return response.future; + } + if (streamBody) { + late StreamController> body; + body = StreamController>( + onListen: () { + if (!bodyStarted.isCompleted) bodyStarted.complete(); + body.add(utf8.encode('chunk')); + }, + onCancel: () => bodyCancelled += 1, + ); + unawaited( + abortTrigger!.then((_) { + aborts += 1; + if (!body.isClosed) { + body.addError(http.RequestAbortedException(request.url)); + unawaited(body.close()); + } + }), + ); + return Future.value(http.StreamedResponse(body.stream, 200)); + } + return Future.value( + http.StreamedResponse(Stream.value(utf8.encode('ok')), 200), + ); + } } DavHttpTransport _nextcloudTransport( diff --git a/test/features/schedule/presentation/schedule_workspace_navigation_test.dart b/test/features/schedule/presentation/schedule_workspace_navigation_test.dart new file mode 100644 index 00000000..f5ac25f6 --- /dev/null +++ b/test/features/schedule/presentation/schedule_workspace_navigation_test.dart @@ -0,0 +1,189 @@ +import 'dart:io'; + +import 'package:busymax/src/app/app_bootstrap.dart'; +import 'package:busymax/src/db/app_database.dart'; +import 'package:busymax/src/features/accounts/data/accounts_repository.dart'; +import 'package:busymax/src/features/schedule/presentation/schedule_day_week_view.dart'; +import 'package:busymax/src/features/schedule/presentation/schedule_month_view.dart'; +import 'package:busymax/src/features/schedule/presentation/schedule_workspace.dart'; +import 'package:busymax/src/features/task_lists/data/task_lists_repository.dart'; +import 'package:busymax/src/features/tasks/data/tasks_repository.dart'; +import 'package:busymax/src/platform/linux_header_bar_service.dart'; +import 'package:busymax/src/platform/linux_header_bar_provider.dart'; +import 'package:busymax/src/providers/busy_provider.dart'; +import 'package:busymax/src/schedule/schedule_scope.dart'; +import 'package:drift/drift.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../../support/process_time_zone.dart'; +import '../../../test_localized_app.dart'; + +void main() { + testWidgets( + 'workspace month commands always select the adjacent month first', + (tester) async { + for (final scenario in <({DateTime start, bool next, DateTime expected})>[ + (start: DateTime(2026, 1, 31), next: true, expected: DateTime(2026, 2)), + ( + start: DateTime(2026, 3, 31), + next: false, + expected: DateTime(2026, 2), + ), + (start: DateTime(2024, 2, 29), next: true, expected: DateTime(2024, 3)), + (start: DateTime(2026, 12, 31), next: true, expected: DateTime(2027)), + ]) { + final harness = await _pumpWorkspace(tester, scenario.start); + await tester.sendKeyEvent(LogicalKeyboardKey.digit3); + await tester.pump(); + await _navigate(tester, next: scenario.next); + final view = tester.widget( + find.byType(ScheduleMonthView), + ); + expect(view.selectedDate, scenario.expected); + expect(view.selectedDate.hour, 0); + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pump(const Duration(milliseconds: 1)); + harness.headerBar.dispose(); + await harness.database.close(); + } + }, + ); + + final supportsPosixTimeZones = Platform.isLinux || Platform.isMacOS; + group( + 'workspace civil day and week commands across DST', + skip: supportsPosixTimeZones + ? false + : 'This regression test requires POSIX setenv and tzset.', + () { + late ProcessTimeZone processTimeZone; + setUpAll(() { + processTimeZone = ProcessTimeZone()..set('America/Vancouver'); + }); + tearDownAll(() => processTimeZone.restore()); + + for (final scenario in <({DateTime start, bool next, int days})>[ + (start: DateTime(2025, 3, 8), next: true, days: 1), + (start: DateTime(2025, 3, 10), next: false, days: 1), + (start: DateTime(2025, 11, 1), next: true, days: 1), + (start: DateTime(2025, 11, 3), next: false, days: 1), + (start: DateTime(2025, 3, 3), next: true, days: 7), + (start: DateTime(2025, 11, 9), next: false, days: 7), + ]) { + testWidgets( + '${scenario.days}-day command from ${scenario.start.toIso8601String()}', + (tester) async { + final harness = await _pumpWorkspace(tester, scenario.start); + await tester.sendKeyEvent( + scenario.days == 1 + ? LogicalKeyboardKey.digit1 + : LogicalKeyboardKey.digit2, + ); + await tester.pump(); + await _navigate(tester, next: scenario.next); + final view = tester.widget( + find.byType(ScheduleDayWeekView), + ); + final direction = scenario.next ? 1 : -1; + final expected = DateTime( + scenario.start.year, + scenario.start.month, + scenario.start.day + direction * scenario.days, + ); + expect(view.selectedDate, expected); + expect(view.selectedDate.hour, 0); + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pump(const Duration(milliseconds: 1)); + harness.headerBar.dispose(); + await harness.database.close(); + }, + ); + } + }, + ); +} + +Future _navigate(WidgetTester tester, {required bool next}) async { + await tester.sendKeyDownEvent(LogicalKeyboardKey.shiftLeft); + await tester.sendKeyEvent( + next ? LogicalKeyboardKey.arrowRight : LogicalKeyboardKey.arrowLeft, + ); + await tester.sendKeyUpEvent(LogicalKeyboardKey.shiftLeft); + await tester.pump(); +} + +Future<({AppDatabase database, LinuxHeaderBarService headerBar})> +_pumpWorkspace(WidgetTester tester, DateTime initialDate) async { + final database = AppDatabase.memoryForTests(); + await database + .into(database.accounts) + .insert( + AccountsCompanion.insert( + id: 'account', + provider: 'google', + authority: 'https://accounts.google.com', + providerAccountId: 'account', + credentialKind: 'oauth', + authState: const Value('signed_in'), + createdAtUtc: _now, + updatedAtUtc: _now, + ), + ); + await database.taskListsDao.upsertTaskList( + TaskListsCompanion.insert( + accountId: 'account', + id: 'list', + title: 'Tasks', + rawJson: '{}', + createdLocalAtUtc: _now, + updatedLocalAtUtc: _now, + ), + ); + const account = AccountEntity( + id: 'account', + provider: BusyProvider.google, + authority: 'https://accounts.google.com', + providerAccountId: 'account', + authState: accountAuthStateSignedIn, + ); + final headerBar = LinuxHeaderBarService(isLinux: false); + await tester.pumpWidget( + ProviderScope( + overrides: [ + databaseProvider.overrideWithValue(database), + accountsStreamProvider.overrideWith((ref) => Stream.value([account])), + activeAccountProvider.overrideWithValue('account'), + localTimeZoneProvider.overrideWithValue('America/Vancouver'), + localSettingsStoreProvider.overrideWithValue(_MemorySettingsStore()), + linuxHeaderBarServiceProvider.overrideWithValue(headerBar), + taskListsRepositoryForAccountProvider.overrideWith( + (ref, id) => TaskListsRepository(database: database, accountId: id), + ), + tasksRepositoryForAccountProvider.overrideWith( + (ref, id) => TasksRepository(database: database, accountId: id), + ), + ], + child: localizedTestApp( + child: ScheduleWorkspace( + initialScope: ScheduleScope.all, + initialDate: initialDate, + ), + ), + ), + ); + await tester.pumpAndSettle(); + return (database: database, headerBar: headerBar); +} + +class _MemorySettingsStore implements LocalSettingsStore { + @override + Future> load() async => const {}; + + @override + Future save(Map json) async {} +} + +const _now = '2026-06-04T00:00:00.000Z'; diff --git a/test/features/sync/conflict_detector_test.dart b/test/features/sync/conflict_detector_test.dart new file mode 100644 index 00000000..d51fc273 --- /dev/null +++ b/test/features/sync/conflict_detector_test.dart @@ -0,0 +1,75 @@ +import 'package:busymax/src/features/sync/conflict_detector.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + const detector = ConflictDetector(); + final baseline = DateTime.utc(2026, 6, 4); + final current = baseline.add(const Duration(minutes: 1)); + + test('separately decoded structured task values compare deeply', () { + final values = { + 'categories': ['Home', 'Important'], + 'recurrence': { + 'pattern': { + 'type': 'weekly', + 'interval': 2, + 'daysOfWeek': ['monday'], + }, + 'range': {'type': 'endDate', 'endDate': '2026-12-31'}, + }, + 'microsoftDueDateTime': { + 'dateTime': '2026-06-05T09:30:00.0000000', + 'timeZone': 'America/Vancouver', + }, + }; + final equivalent = { + 'categories': ['Home', 'Important'], + 'recurrence': { + 'pattern': { + 'type': 'weekly', + 'interval': 2, + 'daysOfWeek': ['monday'], + }, + 'range': {'type': 'endDate', 'endDate': '2026-12-31'}, + }, + 'microsoftDueDateTime': { + 'dateTime': '2026-06-05T09:30:00.0000000', + 'timeZone': 'America/Vancouver', + }, + }; + + final conflict = detector.detect( + entityType: 'task', + entityId: 'task-1', + localPendingFields: values, + lastServerJson: values, + currentServerJson: equivalent, + baselineUpdatedUtc: baseline, + currentUpdatedUtc: current, + ); + + expect(conflict.hasConflict, isFalse); + }); + + test('a genuinely changed nested value remains a conflict', () { + final conflict = detector.detect( + entityType: 'task', + entityId: 'task-1', + localPendingFields: const {'recurrence': true}, + lastServerJson: const { + 'recurrence': { + 'pattern': {'interval': 1}, + }, + }, + currentServerJson: const { + 'recurrence': { + 'pattern': {'interval': 2}, + }, + }, + baselineUpdatedUtc: baseline, + currentUpdatedUtc: current, + ); + + expect(conflict.changedFields, {'recurrence'}); + }); +} diff --git a/test/features/sync/pending_op_resolution_service_test.dart b/test/features/sync/pending_op_resolution_service_test.dart index 7e11f3ef..03b4f6bf 100644 --- a/test/features/sync/pending_op_resolution_service_test.dart +++ b/test/features/sync/pending_op_resolution_service_test.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:convert'; import 'package:busymax/src/calendar_providers/calendar_mutation.dart'; @@ -9,9 +10,11 @@ import 'package:busymax/src/db/app_database.dart'; import 'package:busymax/src/features/calendar/data/calendar_repository.dart'; import 'package:busymax/src/features/calendar/presentation/event_editor_draft.dart'; import 'package:busymax/src/features/sync/pending_op_resolution_service.dart'; +import 'package:busymax/src/features/sync/pending_ops_replayer.dart'; import 'package:busymax/src/features/tasks/domain/task_remote_client.dart'; import 'package:busymax/src/google_tasks/api/google_tasks_api_error.dart'; import 'package:busymax/src/features/tasks/domain/task_remote_models.dart'; +import 'package:busymax/src/features/tasks/domain/task_checklist_item.dart'; import 'package:busymax/src/providers/busy_provider.dart'; void main() { @@ -609,6 +612,319 @@ void main() { expect(taskSyncCalls, 0); }); + test( + 'discard uncertain checklist create removes its chain and merges all pages', + () async { + final localItems = [ + TaskChecklistItemEntity.fromJson(const { + 'id': 'local-step', + 'displayName': 'Uncertain', + 'isChecked': true, + }), + TaskChecklistItemEntity.fromJson(const { + 'id': 'server-sibling', + 'displayName': 'Sibling local edit', + 'isChecked': false, + }), + ]; + await database.tasksDao.upsertTask( + _localTask( + 'task-1', + localDirty: true, + checklistItemsJson: encodeTaskChecklistItems(localItems), + ), + ); + apiClient.checklistPages = { + null: const TaskChecklistItemsPageDto( + items: [ + TaskChecklistItemDto( + id: 'server-created', + title: 'Uncertain', + completed: false, + rawJson: {}, + ), + ], + nextPageToken: 'page-2', + rawJson: {}, + ), + 'page-2': const TaskChecklistItemsPageDto( + items: [ + TaskChecklistItemDto( + id: 'server-sibling', + title: 'Sibling remote', + completed: false, + rawJson: {}, + ), + ], + rawJson: {}, + ), + }; + await _enqueueBlockedOp( + database, + id: 'create-step', + entityType: 'task_checklist_item', + operation: 'create_task_checklist_item', + taskListId: 'list-1', + taskId: 'task-1', + localTempId: 'local-step', + state: 'recovery_required', + request: const { + 'checklistItemId': 'local-step', + 'body': {'displayName': 'Uncertain', 'isChecked': false}, + }, + ); + await _enqueueBlockedOp( + database, + id: 'patch-step', + entityType: 'task_checklist_item', + operation: 'patch_task_checklist_item', + taskListId: 'list-1', + taskId: 'task-1', + dependsOnOpId: 'create-step', + request: const { + 'checklistItemId': 'local-step', + 'body': {'isChecked': true}, + }, + ); + await _enqueueBlockedOp( + database, + id: 'sibling-step', + entityType: 'task_checklist_item', + operation: 'patch_task_checklist_item', + taskListId: 'list-1', + taskId: 'task-1', + request: const { + 'checklistItemId': 'server-sibling', + 'body': {'displayName': 'Sibling local edit'}, + }, + ); + + await service.discard('create-step'); + + expect(apiClient.checklistPageTokens, [null, 'page-2']); + expect(await database.pendingOpsDao.getOp('create-step'), equals(null)); + expect(await database.pendingOpsDao.getOp('patch-step'), equals(null)); + expect( + await database.pendingOpsDao.getOp('sibling-step'), + isNot(equals(null)), + ); + final parent = (await database.tasksDao.listTasks( + 'account', + 'list-1', + )).single; + expect(parent.title, 'Local task'); + expect(parent.localDirty, isTrue); + final items = decodeTaskChecklistItems( + parent.microsoftChecklistItemsJson, + ); + expect(items.map((item) => item.id), [ + 'server-created', + 'server-sibling', + ]); + expect( + items.singleWhere((item) => item.id == 'server-sibling').title, + 'Sibling local edit', + ); + expect(taskSyncCalls, 1); + + await service.discard('create-step'); + expect(apiClient.checklistPageTokens, [null, 'page-2']); + expect(taskSyncCalls, 1); + }, + ); + + test( + 'failed checklist read keeps recovery operation and projection intact', + () async { + final projection = encodeTaskChecklistItems([ + TaskChecklistItemEntity.fromJson(const { + 'id': 'local-step', + 'displayName': 'Uncertain', + 'isChecked': false, + }), + ]); + await database.tasksDao.upsertTask( + _localTask('task-1', checklistItemsJson: projection), + ); + apiClient.checklistError = const GoogleTasksApiError( + statusCode: 503, + message: 'Unavailable', + ); + await _enqueueBlockedOp( + database, + id: 'create-step', + entityType: 'task_checklist_item', + operation: 'create_task_checklist_item', + taskListId: 'list-1', + taskId: 'task-1', + localTempId: 'local-step', + state: 'recovery_required', + request: const {'checklistItemId': 'local-step'}, + ); + + await expectLater( + service.discard('create-step'), + throwsA(isA()), + ); + expect( + await database.pendingOpsDao.getOp('create-step'), + isNot(equals(null)), + ); + expect( + (await database.tasksDao.listTasks( + 'account', + 'list-1', + )).single.microsoftChecklistItemsJson, + projection, + ); + expect(taskSyncCalls, 0); + }, + ); + + test( + 'discard when no checklist item was created removes only the exact account chain', + () async { + final localProjection = encodeTaskChecklistItems([ + TaskChecklistItemEntity.fromJson(const { + 'id': 'local-step', + 'displayName': 'Uncertain', + 'isChecked': true, + }), + ]); + await database.tasksDao.upsertTask( + _localTask('task-1', checklistItemsJson: localProjection), + ); + await _enqueueBlockedOp( + database, + id: 'create-step', + entityType: 'task_checklist_item', + operation: 'create_task_checklist_item', + taskListId: 'list-1', + taskId: 'task-1', + localTempId: 'local-step', + state: 'recovery_required', + request: const {'checklistItemId': 'local-step'}, + ); + await _enqueueBlockedOp( + database, + id: 'patch-step', + entityType: 'task_checklist_item', + operation: 'patch_task_checklist_item', + taskListId: 'list-1', + taskId: 'task-1', + dependsOnOpId: 'create-step', + request: const { + 'checklistItemId': 'local-step', + 'body': {'isChecked': true}, + }, + ); + + await _insertAccount(database, accountId: 'other'); + await database.taskListsDao.upsertTaskList( + _localTaskList('list-1', accountId: 'other'), + ); + await database.tasksDao.upsertTask( + _localTask( + 'task-1', + accountId: 'other', + checklistItemsJson: localProjection, + ), + ); + await _enqueueBlockedOp( + database, + id: 'other-create-step', + accountId: 'other', + entityType: 'task_checklist_item', + operation: 'create_task_checklist_item', + taskListId: 'list-1', + taskId: 'task-1', + localTempId: 'local-step', + state: 'recovery_required', + request: const {'checklistItemId': 'local-step'}, + ); + + await service.discard('create-step'); + + expect(apiClient.checklistPageTokens, [null]); + expect( + decodeTaskChecklistItems( + (await database.tasksDao.listTasks( + 'account', + 'list-1', + )).single.microsoftChecklistItemsJson, + ), + isEmpty, + ); + expect(await database.pendingOpsDao.getOp('create-step'), equals(null)); + expect(await database.pendingOpsDao.getOp('patch-step'), equals(null)); + expect( + await database.pendingOpsDao.getOp('other-create-step'), + isNot(equals(null)), + ); + expect( + decodeTaskChecklistItems( + (await database.tasksDao.listTasks( + 'other', + 'list-1', + )).single.microsoftChecklistItemsJson, + ).single.id, + 'local-step', + ); + }, + ); + + test('checklist discard remains serialized with replay', () async { + final started = Completer(); + final gate = Completer(); + apiClient + ..checklistReadStarted = started + ..checklistReadGate = gate; + await database.tasksDao.upsertTask( + _localTask( + 'task-1', + checklistItemsJson: encodeTaskChecklistItems([ + TaskChecklistItemEntity.fromJson(const { + 'id': 'local-step', + 'displayName': 'Uncertain', + 'isChecked': false, + }), + ]), + ), + ); + await _enqueueBlockedOp( + database, + id: 'create-step', + entityType: 'task_checklist_item', + operation: 'create_task_checklist_item', + taskListId: 'list-1', + taskId: 'task-1', + localTempId: 'local-step', + state: 'recovery_required', + request: const {'checklistItemId': 'local-step'}, + ); + + final discard = service.discard('create-step'); + await started.future; + var replayCompleted = false; + final replay = + PendingOpsReplayer( + database: database, + apiClient: apiClient, + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 6, 4), + ).replayDueOps().then((value) { + replayCompleted = true; + return value; + }); + await Future.delayed(Duration.zero); + expect(replayCompleted, isFalse); + + gate.complete(); + await discard; + expect(await replay, 0); + expect(await database.pendingOpsDao.getOp('create-step'), equals(null)); + }); + test( 'retry routes event synchronization through the calendar callback', () async { @@ -635,11 +951,34 @@ void main() { ); } -class _FakeTaskRemoteClient implements TaskRemoteClient { +class _FakeTaskRemoteClient + implements TaskRemoteClient, TaskChecklistRemoteClient { TaskDto? remoteTask; TaskListDto? remoteTaskList; GoogleTasksApiError? getTaskError; GoogleTasksApiError? getTaskListError; + GoogleTasksApiError? checklistError; + Map checklistPages = const {}; + final checklistPageTokens = []; + Completer? checklistReadStarted; + Completer? checklistReadGate; + + @override + Future listChecklistItemsPage({ + required String taskListId, + required String taskId, + String? pageToken, + }) async { + checklistPageTokens.add(pageToken); + if (checklistReadStarted?.isCompleted == false) { + checklistReadStarted!.complete(); + } + await checklistReadGate?.future; + final error = checklistError; + if (error != null) throw error; + return checklistPages[pageToken] ?? + const TaskChecklistItemsPageDto(items: [], rawJson: {}); + } @override Future getTask({ @@ -666,17 +1005,20 @@ class _FakeTaskRemoteClient implements TaskRemoteClient { dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); } -Future _insertAccount(AppDatabase database) { +Future _insertAccount( + AppDatabase database, { + String accountId = 'account', +}) { return database .into(database.accounts) .insert( AccountsCompanion.insert( - id: 'account', + id: accountId, provider: 'google', authority: 'https://accounts.google.com', - providerAccountId: 'google-account', + providerAccountId: 'google-$accountId', credentialKind: 'oauth', - email: const Value('google-account@example.com'), + email: Value('google-$accountId@example.com'), createdAtUtc: _now, updatedAtUtc: _now, ), @@ -702,12 +1044,13 @@ Future _blockOperation(AppDatabase database, String operationId) { TaskListsCompanion _localTaskList( String id, { + String accountId = 'account', String title = 'List', bool localDirty = false, bool pendingDelete = false, }) { return TaskListsCompanion.insert( - accountId: 'account', + accountId: accountId, id: id, title: title, rawJson: '{"id":"$id","title":"$title"}', @@ -720,13 +1063,15 @@ TaskListsCompanion _localTaskList( TasksCompanion _localTask( String id, { + String accountId = 'account', String taskListId = 'list-1', bool localDirty = false, bool pendingDelete = false, bool pendingMove = false, + String? checklistItemsJson, }) { return TasksCompanion.insert( - accountId: 'account', + accountId: accountId, taskListId: taskListId, id: id, title: 'Local task', @@ -734,6 +1079,7 @@ TasksCompanion _localTask( localDirty: Value(localDirty), pendingDelete: Value(pendingDelete), pendingMove: Value(pendingMove), + microsoftChecklistItemsJson: Value(checklistItemsJson), createdLocalAtUtc: _now, updatedLocalAtUtc: _now, ); @@ -741,6 +1087,8 @@ TasksCompanion _localTask( Future _enqueueBlockedOp( AppDatabase database, { + String id = 'op-1', + String accountId = 'account', String entityType = 'task', required String operation, String? operationType, @@ -748,17 +1096,21 @@ Future _enqueueBlockedOp( String? taskId, String? calendarSourceId, String state = 'pending', + String? localTempId, + String? dependsOnOpId, Map request = const {}, }) { return database.pendingOpsDao.enqueue( PendingOpsCompanion.insert( - id: 'op-1', - accountId: 'account', + id: id, + accountId: accountId, entityType: entityType, operation: operation, operationType: Value(operationType), taskListId: Value(taskListId), taskId: Value(taskId), + localTempId: Value(localTempId), + dependsOnOpId: Value(dependsOnOpId), calendarSourceId: Value(calendarSourceId), requestJson: jsonEncode(request), state: Value(state), diff --git a/test/features/sync/pending_ops_replayer_test.dart b/test/features/sync/pending_ops_replayer_test.dart index 92a6fcf4..e70744cc 100644 --- a/test/features/sync/pending_ops_replayer_test.dart +++ b/test/features/sync/pending_ops_replayer_test.dart @@ -5,12 +5,14 @@ import 'dart:math'; import 'package:busymax/src/features/notifications/notification_schedule_service.dart'; import 'package:busymax/src/features/notifications/notification_scheduler.dart'; import 'package:busymax/src/features/notifications/desktop_notification_service.dart'; +import 'package:busymax/src/core/http/request_dispatch_exception.dart'; import 'package:drift/drift.dart'; import 'package:drift/native.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:busymax/src/db/app_database.dart'; import 'package:busymax/src/features/sync/pending_ops_replayer.dart'; import 'package:busymax/src/features/sync/sync_engine.dart'; +import 'package:busymax/src/features/task_lists/data/task_lists_repository.dart'; import 'package:busymax/src/app/app_settings.dart'; import 'package:busymax/src/schedule/schedule_sidebar_order.dart'; import 'package:busymax/src/features/tasks/data/tasks_repository.dart'; @@ -727,6 +729,525 @@ void main() { expect(destination.single.id, 'task-server'); }); + test( + 'cross-list move orders an equal-timestamp edit and preserves projection', + () async { + await database.taskListsDao.upsertTaskList(_taskList('list-2')); + await database.tasksDao.upsertTask( + _task('list-1', 'task-1', title: 'Original'), + ); + final repository = TasksRepository( + database: database, + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 6, 4), + ); + await repository.moveTask( + const TaskMoveInput( + sourceTaskListId: 'list-1', + taskId: 'task-1', + destinationTaskListId: 'list-2', + ), + ); + await repository.patchTask( + 'list-2', + 'task-1', + const TaskPatchInput({'title': 'Edited after move'}), + ); + final queued = await database.select(database.pendingOps).get(); + final move = queued.singleWhere((op) => op.operation == 'move_task'); + final edit = queued.singleWhere((op) => op.operation == 'patch_task'); + expect(edit.dependsOnOpId, move.id); + + apiClient.moveTaskError = const GoogleTasksApiError( + statusCode: 429, + message: 'Rate limited', + ); + expect( + await PendingOpsReplayer( + database: database, + apiClient: apiClient, + accountId: 'account', + random: Random(0), + nowUtc: () => DateTime.utc(2026, 6, 4, 1), + ).replayDueOps(), + 0, + ); + expect(apiClient.calls, ['move_task:task-1']); + expect( + (await database.tasksDao.listTasks('account', 'list-2')).single.title, + 'Edited after move', + ); + + apiClient.moveTaskError = null; + expect( + await PendingOpsReplayer( + database: database, + apiClient: apiClient, + accountId: 'account', + random: Random(0), + nowUtc: () => DateTime.utc(2026, 6, 4, 2), + ).replayDueOps(), + 2, + ); + expect(apiClient.calls, [ + 'move_task:task-1', + 'move_task:task-1', + 'patch_task:task-1', + ]); + expect(await database.tasksDao.listTasks('account', 'list-1'), isEmpty); + final finalTask = (await database.tasksDao.listTasks( + 'account', + 'list-2', + )).single; + expect(finalTask.title, 'Edited after move'); + expect(finalTask.localDirty, isFalse); + }, + ); + + test( + 'three cross-list moves form one dependency chain before an edit', + () async { + await database.taskListsDao.upsertTaskList(_taskList('list-2')); + await database.taskListsDao.upsertTaskList(_taskList('list-3')); + await database.tasksDao.upsertTask(_task('list-1', 'task-1')); + final repository = TasksRepository( + database: database, + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 6, 4), + ); + await repository.moveTask( + const TaskMoveInput( + sourceTaskListId: 'list-1', + taskId: 'task-1', + destinationTaskListId: 'list-2', + ), + ); + await repository.moveTask( + const TaskMoveInput( + sourceTaskListId: 'list-2', + taskId: 'task-1', + destinationTaskListId: 'list-3', + ), + ); + await repository.patchTask( + 'list-3', + 'task-1', + const TaskPatchInput({'title': 'Final title'}), + ); + + expect( + await PendingOpsReplayer( + database: database, + apiClient: apiClient, + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 6, 4, 1), + ).replayDueOps(), + 3, + ); + expect(apiClient.calls, [ + 'move_task:task-1', + 'move_task:task-1', + 'patch_task:task-1', + ]); + expect( + (await database.tasksDao.listTasks('account', 'list-3')).single.title, + 'Final title', + ); + }, + ); + + test( + 'remote edit after a move still conflicts with the dependent patch', + () async { + const baselineUpdated = '2026-06-04T00:00:00.000Z'; + final baselineRaw = jsonEncode({ + 'id': 'task-1', + 'title': 'Base title', + 'updated': baselineUpdated, + }); + await database.taskListsDao.upsertTaskList(_taskList('list-2')); + await database.tasksDao.upsertTask( + _task( + 'list-1', + 'task-1', + title: 'Base title', + updatedUtc: baselineUpdated, + rawJson: baselineRaw, + ), + ); + apiClient + ..remoteTask = _taskDto( + 'task-1', + title: 'Base title', + updated: DateTime.parse(baselineUpdated), + ) + ..remoteTaskAfterMove = _taskDto( + 'task-1', + title: 'Independent remote title', + updated: DateTime.utc(2026, 6, 4, 0, 10), + ); + final repository = TasksRepository( + database: database, + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 6, 4), + ); + await repository.moveTask( + const TaskMoveInput( + sourceTaskListId: 'list-1', + taskId: 'task-1', + destinationTaskListId: 'list-2', + ), + ); + await repository.patchTask( + 'list-2', + 'task-1', + const TaskPatchInput({'title': 'Local title'}), + ); + + expect( + await PendingOpsReplayer( + database: database, + apiClient: apiClient, + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 6, 4, 1), + ).replayDueOps(), + 1, + ); + + expect(apiClient.calls, ['move_task:task-1']); + final pending = await database.select(database.pendingOps).getSingle(); + expect(pending.operation, 'patch_task'); + expect(pending.lastErrorCode, 'conflict'); + expect(pending.lastErrorMessage, contains('title')); + expect( + (await database.tasksDao.listTasks('account', 'list-2')).single.title, + 'Local title', + ); + }, + ); + + test( + 'delete remains blocked behind a permanently rejected cross-list move', + () async { + await database.taskListsDao.upsertTaskList(_taskList('list-2')); + await database.tasksDao.upsertTask(_task('list-1', 'task-1')); + final repository = TasksRepository( + database: database, + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 6, 4), + ); + await repository.moveTask( + const TaskMoveInput( + sourceTaskListId: 'list-1', + taskId: 'task-1', + destinationTaskListId: 'list-2', + ), + ); + await repository.deleteTask('list-2', 'task-1'); + apiClient.moveTaskError = const GoogleTasksApiError( + statusCode: 400, + message: 'Rejected', + ); + + expect( + await PendingOpsReplayer( + database: database, + apiClient: apiClient, + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 6, 4, 1), + ).replayDueOps(), + 0, + ); + expect(apiClient.calls, ['move_task:task-1']); + final operations = await database.select(database.pendingOps).get(); + final move = operations.singleWhere((op) => op.operation == 'move_task'); + final delete = operations.singleWhere( + (op) => op.operation == 'delete_task', + ); + expect(move.nextAttemptAtUtc, startsWith('9999-12-31')); + expect(delete.dependsOnOpId, move.id); + }, + ); + + test('known pre-dispatch create failure remains safely retryable', () async { + await database.tasksDao.upsertTask( + _task('list-1', 'local-task-safe', title: 'Draft'), + ); + await _enqueue( + database, + id: 'safe-create', + operation: 'create_task', + taskListId: 'list-1', + taskId: 'local-task-safe', + localTempId: 'local-task-safe', + request: { + 'body': {'title': 'Draft'}, + }, + ); + apiClient.createTaskError = const KnownUnsentRequestException( + kind: RequestPreDispatchFailureKind.authentication, + ); + expect( + await PendingOpsReplayer( + database: database, + apiClient: apiClient, + accountId: 'account', + random: Random(0), + nowUtc: () => DateTime.utc(2026, 6, 4), + ).replayDueOps(), + 0, + ); + final retry = await database.pendingOpsDao.getOp('safe-create'); + expect(retry!.state, 'retry'); + expect(retry.lastErrorCode, 'authentication_failed_before_dispatch'); + + apiClient.createTaskError = null; + expect( + await PendingOpsReplayer( + database: database, + apiClient: apiClient, + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 6, 4, 2), + ).replayDueOps(), + 1, + ); + expect( + apiClient.calls.where((call) => call == 'create_task:list-1'), + hasLength(2), + ); + }); + + test( + 'equal-timestamp task-list renames replay in dependency order', + () async { + apiClient.persistTaskListPatches = true; + apiClient.remoteTaskList = _taskListDto('list-1', title: 'A'); + await database.taskListsDao.upsertTaskList( + _taskList('list-1', title: 'A'), + ); + final repository = TaskListsRepository( + database: database, + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 6, 4), + ); + await repository.renameTaskList('list-1', 'B'); + await repository.renameTaskList('list-1', 'C'); + final queued = await database.select(database.pendingOps).get(); + expect(queued, hasLength(2)); + expect(queued.last.dependsOnOpId, queued.first.id); + + expect( + await PendingOpsReplayer( + database: database, + apiClient: apiClient, + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 6, 4, 1), + ).replayDueOps(), + 2, + ); + expect(apiClient.taskListPatchTitles, ['B', 'C']); + expect(apiClient.remoteTaskList!.title, 'C'); + final local = (await database.taskListsDao.listTaskLists( + 'account', + )).single; + expect(local.title, 'C'); + expect(local.localDirty, isFalse); + }, + ); + + test( + 'in-flight task-list rename response does not revert a newer title', + () async { + final started = Completer(); + final gate = Completer(); + apiClient + ..persistTaskListPatches = true + ..taskListPatchStarted = started + ..taskListPatchGate = gate; + final repository = TaskListsRepository( + database: database, + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 6, 4), + ); + await repository.renameTaskList('list-1', 'B'); + final replay = PendingOpsReplayer( + database: database, + apiClient: apiClient, + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 6, 4, 1), + ).replayDueOps(); + await started.future; + await repository.renameTaskList('list-1', 'C'); + gate.complete(); + expect(await replay, 1); + expect( + (await database.taskListsDao.listTaskLists('account')).single.title, + 'C', + ); + apiClient.taskListPatchGate = null; + expect( + await PendingOpsReplayer( + database: database, + apiClient: apiClient, + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 6, 4, 2), + ).replayDueOps(), + 1, + ); + expect(apiClient.remoteTaskList!.title, 'C'); + }, + ); + + test( + 'dependent list rename waits through a retryable first rename', + () async { + final repository = TaskListsRepository( + database: database, + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 6, 4), + ); + await repository.renameTaskList('list-1', 'B'); + await repository.renameTaskList('list-1', 'C'); + apiClient.patchTaskListError = const GoogleTasksApiError( + statusCode: 429, + message: 'Rate limited', + ); + + expect( + await PendingOpsReplayer( + database: database, + apiClient: apiClient, + accountId: 'account', + random: Random(0), + nowUtc: () => DateTime.utc(2026, 6, 4, 1), + ).replayDueOps(), + 0, + ); + expect(apiClient.taskListPatchTitles, isEmpty); + expect( + (await database.taskListsDao.listTaskLists('account')).single.title, + 'C', + ); + + apiClient.patchTaskListError = null; + expect( + await PendingOpsReplayer( + database: database, + apiClient: apiClient, + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 6, 4, 2), + ).replayDueOps(), + 2, + ); + expect(apiClient.taskListPatchTitles, ['B', 'C']); + expect( + (await database.taskListsDao.listTaskLists('account')).single.title, + 'C', + ); + }, + ); + + test('task-list rename followed by full update replays in order', () async { + final repository = TaskListsRepository( + database: database, + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 6, 4), + ); + await repository.renameTaskList('list-1', 'B'); + await repository.updateTaskListFull( + 'list-1', + const TaskListPut({'title': 'C'}), + ); + + expect( + await PendingOpsReplayer( + database: database, + apiClient: apiClient, + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 6, 4, 1), + ).replayDueOps(), + 2, + ); + expect(apiClient.calls, [ + 'patch_task_list:list-1', + 'update_task_list:list-1', + ]); + final local = (await database.taskListsDao.listTaskLists('account')).single; + expect(local.title, 'C'); + expect(local.localDirty, isFalse); + }); + + for (final reminderValue in [false, true]) { + test( + 'list creation preserves in-flight reminder value $reminderValue and rename', + () async { + final started = Completer(); + final gate = Completer(); + apiClient + ..createTaskListStarted = started + ..createTaskListGate = gate; + final repository = TaskListsRepository( + database: database, + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 6, 4), + ); + await repository.createTaskList('Temporary'); + final temporary = (await database.taskListsDao.listTaskLists( + 'account', + )).singleWhere((list) => list.id.startsWith('local-tasklist-')); + await repository.renameTaskList(temporary.id, 'Renamed'); + await database.tasksDao.upsertTask( + TasksCompanion.insert( + accountId: 'account', + taskListId: temporary.id, + id: 'task-in-temporary-list', + title: 'Reminder task', + status: const Value('needsAction'), + microsoftIsReminderOn: const Value(true), + microsoftReminderDateTime: const Value('2026-06-05T09:00:00.000Z'), + microsoftReminderTimeZone: const Value('UTC'), + rawJson: '{"id":"task-in-temporary-list"}', + createdLocalAtUtc: _now, + updatedLocalAtUtc: _now, + ), + ); + await repository.setRemindersEnabled(temporary.id, !reminderValue); + + final replay = PendingOpsReplayer( + database: database, + apiClient: apiClient, + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 6, 4, 1), + ).replayDueOps(); + await started.future; + await repository.setRemindersEnabled(temporary.id, reminderValue); + gate.complete(); + expect(await replay, 2); + + final lists = await database.taskListsDao.listTaskLists('account'); + expect(lists.any((list) => list.id == temporary.id), isFalse); + final server = lists.singleWhere((list) => list.id == 'list-server'); + expect(server.remindersEnabled, reminderValue); + expect(server.title, 'Renamed'); + expect( + (await database.tasksDao.listTasks( + 'account', + 'list-server', + )).single.id, + 'task-in-temporary-list', + ); + await NotificationScheduleService( + database: database, + nowUtc: () => DateTime.utc(2026, 6, 4, 1), + ).rebuildUpcomingTaskNotifications('account'); + expect( + await database.select(database.notificationSchedule).get(), + hasLength(reminderValue ? 1 : 0), + ); + }, + ); + } + test( 'Google subtask is moved under its parent after a root insert', () async { @@ -1967,6 +2488,7 @@ class _FakeTaskRemoteClient implements TaskRemoteClient { final taskPatchFields = >[]; final createParentTaskIds = []; final moveParentTaskIds = []; + final taskListPatchTitles = []; GoogleTasksApiError? patchTaskListError; Object? createTaskListError; Object? createTaskError; @@ -1975,14 +2497,24 @@ class _FakeTaskRemoteClient implements TaskRemoteClient { GoogleTasksApiError? moveTaskError; TaskListDto? remoteTaskList; TaskDto? remoteTask; + TaskDto? remoteTaskAfterMove; Completer? createTaskGate; + Completer? createTaskListStarted; + Completer? createTaskListGate; + Completer? taskListPatchStarted; + Completer? taskListPatchGate; bool persistTaskPatches = false; + bool persistTaskListPatches = false; int _taskPatchRevision = 0; TasksPageDto remoteTasksPage = const TasksPageDto(items: [], rawJson: {}); @override Future createTaskList({required String title}) async { calls.add('create_task_list:$title'); + if (createTaskListStarted?.isCompleted == false) { + createTaskListStarted!.complete(); + } + await createTaskListGate?.future; final error = createTaskListError; if (error != null) throw error; return _taskListDto('list-server', title: title); @@ -1998,7 +2530,15 @@ class _FakeTaskRemoteClient implements TaskRemoteClient { throw error; } calls.add('patch_task_list:$taskListId'); - return _taskListDto(taskListId, title: patch.fields['title'].toString()); + final title = patch.fields['title'].toString(); + taskListPatchTitles.add(title); + if (taskListPatchStarted?.isCompleted == false) { + taskListPatchStarted!.complete(); + } + await taskListPatchGate?.future; + final result = _taskListDto(taskListId, title: title); + if (persistTaskListPatches) remoteTaskList = result; + return result; } @override @@ -2095,7 +2635,10 @@ class _FakeTaskRemoteClient implements TaskRemoteClient { moveParentTaskIds.add(parentTaskId); final error = moveTaskError; if (error != null) throw error; - return _taskDto(taskId, title: 'Moved', parent: parentTaskId); + final result = _taskDto(taskId, title: 'Moved', parent: parentTaskId); + final afterMove = remoteTaskAfterMove; + if (afterMove != null) remoteTask = afterMove; + return result; } @override @@ -2220,6 +2763,7 @@ Future _insertAccount(AppDatabase database) { authority: 'https://accounts.google.com', providerAccountId: 'google-account', credentialKind: 'oauth', + authState: const Value('signed_in'), createdAtUtc: _now, updatedAtUtc: _now, ), diff --git a/test/features/task_lists/data/task_lists_repository_test.dart b/test/features/task_lists/data/task_lists_repository_test.dart index 9b022787..11fb2d4e 100644 --- a/test/features/task_lists/data/task_lists_repository_test.dart +++ b/test/features/task_lists/data/task_lists_repository_test.dart @@ -139,6 +139,28 @@ void main() { expect(mutationQueuedCalls, 1); }); + test('rename followed by full update forms an explicit list chain', () async { + await database.taskListsDao.upsertTaskList(_taskList('list-1')); + await repository.renameTaskList('list-1', 'First'); + await repository.updateTaskListFull( + 'list-1', + const TaskListPut({'title': 'Second'}), + ); + + final operations = await database.select(database.pendingOps).get(); + final rename = operations.singleWhere( + (operation) => operation.operation == 'patch_task_list', + ); + final update = operations.singleWhere( + (operation) => operation.operation == 'update_task_list', + ); + expect(update.dependsOnOpId, rename.id); + expect( + (await database.taskListsDao.listTaskLists('account')).single.title, + 'Second', + ); + }); + test( 'offline list deletion cancels pending and displayed reminders', () async { diff --git a/test/google_tasks/api/tasks_insert_test.dart b/test/google_tasks/api/tasks_insert_test.dart index df6b13b9..79b62b26 100644 --- a/test/google_tasks/api/tasks_insert_test.dart +++ b/test/google_tasks/api/tasks_insert_test.dart @@ -2,6 +2,8 @@ import 'dart:convert'; import 'package:flutter_test/flutter_test.dart'; import 'package:busymax/src/features/tasks/domain/task_remote_models.dart'; +import 'package:busymax/src/core/http/request_dispatch_exception.dart'; +import 'package:busymax/src/google_tasks/api/google_tasks_api_client.dart'; import 'test_api_client_support.dart'; @@ -38,4 +40,28 @@ void main() { }); expectBearer(request); }); + + test('authorization failure is reported before create dispatch', () async { + final httpClient = RecordingClient(taskJson()); + final client = GoogleTasksRestApiClient( + httpClient: httpClient, + baseUri: Uri.parse('https://tasks.googleapis.com'), + authorizationHeaderProvider: () async => throw StateError('signed out'), + ); + + await expectLater( + client.createTask( + taskListId: 'list-1', + create: TaskCreate(title: 'Task'), + ), + throwsA( + isA().having( + (error) => error.kind, + 'kind', + RequestPreDispatchFailureKind.authentication, + ), + ), + ); + expect(httpClient.requests, isEmpty); + }); } diff --git a/test/microsoft_todo/api/microsoft_todo_api_client_test.dart b/test/microsoft_todo/api/microsoft_todo_api_client_test.dart index 6dc4702a..0cff6c81 100644 --- a/test/microsoft_todo/api/microsoft_todo_api_client_test.dart +++ b/test/microsoft_todo/api/microsoft_todo_api_client_test.dart @@ -4,6 +4,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:http/http.dart' as http; import 'package:http/testing.dart'; import 'package:busymax/src/microsoft_todo/api/microsoft_todo_api_client.dart'; +import 'package:busymax/src/core/http/request_dispatch_exception.dart'; void main() { test('list lists sends GET /me/todo/lists', () async { @@ -193,6 +194,34 @@ void main() { expect(refreshes, 1); expect(headers, ['Bearer old-token', 'Bearer new-token']); }); + + test( + '401 followed by refresh failure is known safe before retry dispatch', + () async { + var calls = 0; + final client = MicrosoftTodoRestApiClient( + httpClient: MockClient((_) async { + calls += 1; + return http.Response('unauthorized', 401); + }), + baseUri: Uri.parse('https://graph.microsoft.com/v1.0'), + authorizationHeaderProvider: () async => 'Bearer token', + unauthorizedRefreshProvider: () async => throw StateError('signed out'), + ); + + await expectLater( + client.createTaskList(displayName: 'Inbox'), + throwsA( + isA().having( + (error) => error.kind, + 'kind', + RequestPreDispatchFailureKind.authentication, + ), + ), + ); + expect(calls, 1); + }, + ); } MicrosoftTodoRestApiClient _client( diff --git a/test/microsoft_todo/api/microsoft_todo_task_remote_client_test.dart b/test/microsoft_todo/api/microsoft_todo_task_remote_client_test.dart index 280418c0..425f7a20 100644 --- a/test/microsoft_todo/api/microsoft_todo_task_remote_client_test.dart +++ b/test/microsoft_todo/api/microsoft_todo_task_remote_client_test.dart @@ -16,6 +16,50 @@ import 'package:drift/drift.dart'; import 'package:drift/native.dart'; void main() { + test('conflict normalization maps aliases without mutating snapshots', () { + final adapter = MicrosoftTodoTaskRemoteClient( + client: _FakeMicrosoftTodoApiClient(), + defaultTimeZone: 'UTC', + ); + final snapshot = { + 'displayName': 'Inbox', + 'body': {'content': 'Notes', 'contentType': 'text'}, + 'dueDateTime': { + 'dateTime': '2026-06-06T09:00:00.0000000', + 'timeZone': 'UTC', + }, + 'startDateTime': { + 'dateTime': '2026-06-05T09:00:00.0000000', + 'timeZone': 'UTC', + }, + 'reminderDateTime': { + 'dateTime': '2026-06-06T08:30:00.0000000', + 'timeZone': 'UTC', + }, + 'completedDateTime': { + 'dateTime': '2026-06-07T09:00:00.0000000', + 'timeZone': 'UTC', + }, + 'isReminderOn': true, + }; + final before = jsonEncode(snapshot); + + final task = adapter.normalizeTaskConflictSnapshot(snapshot); + final list = adapter.normalizeTaskListConflictSnapshot(snapshot); + + expect(task['notes'], 'Notes'); + expect(task['due'], '2026-06-06'); + expect(task['microsoftDueTimeZone'], 'UTC'); + expect(task['microsoftStartTimeZone'], 'UTC'); + expect(task['microsoftReminderTimeZone'], 'UTC'); + expect(task['microsoftCompletedTimeZone'], 'UTC'); + expect(task['microsoftIsReminderOn'], isTrue); + expect(list['title'], 'Inbox'); + expect(jsonEncode(snapshot), before); + expect(task, isNot(same(snapshot))); + expect(list, isNot(same(snapshot))); + }); + test( 'create task maps neutral mutation fields to Microsoft Graph body', () async { From 25090fde1e78200058f9703f911691449d056a53 Mon Sep 17 00:00:00 2001 From: albert Date: Fri, 18 Sep 2026 21:00:45 -0700 Subject: [PATCH 20/25] Introduce `NativeTerminatingHttpClient` for precise connection termination during sync; fix pending operations, optimize baseline resolution, and update connectivity-aware HTTP handling. --- lib/src/app/app_bootstrap.dart | 3 +- .../http/native_terminating_http_client.dart | 65 +++++++ .../core/http/terminating_http_client.dart | 11 ++ lib/src/dav/http/dav_http_transport.dart | 50 ++++-- .../network_connectivity_service.dart | 28 ++- .../features/sync/pending_ops_replayer.dart | 41 +++-- test/dav/http/dav_http_transport_test.dart | 90 ++++++++++ .../network_connectivity_service_test.dart | 46 +++++ .../sync/pending_ops_replayer_test.dart | 166 +++++++++++++++++- 9 files changed, 465 insertions(+), 35 deletions(-) create mode 100644 lib/src/core/http/native_terminating_http_client.dart create mode 100644 lib/src/core/http/terminating_http_client.dart diff --git a/lib/src/app/app_bootstrap.dart b/lib/src/app/app_bootstrap.dart index bca10962..55631383 100644 --- a/lib/src/app/app_bootstrap.dart +++ b/lib/src/app/app_bootstrap.dart @@ -11,6 +11,7 @@ import 'package:busymax/src/features/tasks/domain/task_capabilities.dart'; import '../core/time/provider_date_time.dart'; import '../core/auth/account_token_broker.dart'; +import '../core/http/native_terminating_http_client.dart'; import '../core/time/stored_temporal_projection.dart'; import '../dav/nextcloud/nextcloud_native_export.dart'; import '../config/build_config.dart'; @@ -121,7 +122,7 @@ final networkReconnectSyncCoordinatorProvider = final baseHttpClientProvider = Provider((ref) { final client = ConnectivityAwareHttpClient( - inner: http.Client(), + inner: NativeTerminatingHttpClient(), requireNetwork: ref .watch(networkConnectivityMonitorProvider) .requireNetwork, diff --git a/lib/src/core/http/native_terminating_http_client.dart b/lib/src/core/http/native_terminating_http_client.dart new file mode 100644 index 00000000..36604442 --- /dev/null +++ b/lib/src/core/http/native_terminating_http_client.dart @@ -0,0 +1,65 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:http/http.dart' as http; +import 'package:http/io_client.dart'; + +import 'terminating_http_client.dart'; + +/// Uses the shared client for ordinary requests and an isolated native client +/// for requests that need connection-establishment termination. +final class NativeTerminatingHttpClient extends http.BaseClient + implements TerminatingHttpClient { + NativeTerminatingHttpClient({http.Client? sharedClient}) + : _sharedClient = sharedClient ?? http.Client(); + + final http.Client _sharedClient; + var _closed = false; + + @override + Future send(http.BaseRequest request) { + if (_closed) { + throw http.ClientException('HTTP client is closed.', request.url); + } + return _sharedClient.send(request); + } + + @override + Future sendTerminating( + http.BaseRequest request, { + required Future terminate, + required Duration connectionTimeout, + }) async { + if (_closed) { + throw http.ClientException('HTTP client is closed.', request.url); + } + final nativeClient = HttpClient()..connectionTimeout = connectionTimeout; + final attemptClient = IOClient(nativeClient); + var terminated = false; + unawaited( + terminate.whenComplete(() { + terminated = true; + // This client belongs only to this request. Force-closing it can + // interrupt openUrl without cancelling sibling operations. + nativeClient.close(force: true); + }), + ); + try { + final response = await attemptClient.send(request); + if (terminated) { + throw http.RequestAbortedException(request.url); + } + return response; + } on Object { + nativeClient.close(force: true); + rethrow; + } + } + + @override + void close() { + if (_closed) return; + _closed = true; + _sharedClient.close(); + } +} diff --git a/lib/src/core/http/terminating_http_client.dart b/lib/src/core/http/terminating_http_client.dart new file mode 100644 index 00000000..caaa8a83 --- /dev/null +++ b/lib/src/core/http/terminating_http_client.dart @@ -0,0 +1,11 @@ +import 'package:http/http.dart' as http; + +/// An HTTP client that can terminate work which has not yet reached +/// [http.Abortable.abortTrigger], such as native connection establishment. +abstract interface class TerminatingHttpClient implements http.Client { + Future sendTerminating( + http.BaseRequest request, { + required Future terminate, + required Duration connectionTimeout, + }); +} diff --git a/lib/src/dav/http/dav_http_transport.dart b/lib/src/dav/http/dav_http_transport.dart index 07e116a3..ea1ddaa6 100644 --- a/lib/src/dav/http/dav_http_transport.dart +++ b/lib/src/dav/http/dav_http_transport.dart @@ -6,6 +6,7 @@ import 'dart:typed_data'; import 'package:http/http.dart' as http; +import '../../core/http/terminating_http_client.dart'; import '../../dav/dav_errors.dart'; import '../dav_provider_profile.dart'; import '../../providers/busy_provider.dart'; @@ -364,18 +365,25 @@ final class DavHttpTransport { outbound.bodyBytes = body; } - final streamed = await _sendAttempt( - outbound, - abort, - context, - request.correlationId, - ); - final responseBytes = await _readBoundedBody( - streamed, - abort, - context, - request.correlationId, - ); + late final http.StreamedResponse streamed; + late final Uint8List responseBytes; + try { + streamed = await _sendAttempt( + outbound, + abort, + context, + request.correlationId, + ); + responseBytes = await _readBoundedBody( + streamed, + abort, + context, + request.correlationId, + ); + } finally { + // Also closes an isolated native attempt after its body is consumed. + if (!abort.isCompleted) abort.complete(); + } final headers = { for (final entry in streamed.headers.entries) entry.key.toLowerCase(): entry.value, @@ -533,7 +541,14 @@ final class DavHttpTransport { ); } }); - final sendFuture = _client.send(request); + final client = _client; + final sendFuture = client is TerminatingHttpClient + ? client.sendTerminating( + request, + terminate: abort.future, + connectionTimeout: _limits.connectTimeout, + ) + : client.send(request); final cancellationFuture = context.whenCancelled .then( (_) => throw context.cancellationException, @@ -546,11 +561,10 @@ final class DavHttpTransport { ]); } on Object { if (!abort.isCompleted) abort.complete(); - try { - await sendFuture; - } on Object { - // The original failure below retains the timeout/cancellation reason. - } + // Production native requests receive an operation-local force-close + // above. Observe late completion without extending the caller's + // configured deadline or leaking an asynchronous error. + unawaited(sendFuture.then((_) {}, onError: (_, _) {})); rethrow; } finally { timer.cancel(); diff --git a/lib/src/features/connectivity/network_connectivity_service.dart b/lib/src/features/connectivity/network_connectivity_service.dart index 74023b1f..2fa0c0a3 100644 --- a/lib/src/features/connectivity/network_connectivity_service.dart +++ b/lib/src/features/connectivity/network_connectivity_service.dart @@ -5,6 +5,7 @@ import 'package:http/http.dart' as http; import 'package:logging/logging.dart'; import '../../core/http/request_dispatch_exception.dart'; +import '../../core/http/terminating_http_client.dart'; import '../../core/logging/redacting_logger.dart'; enum NetworkAvailability { unknown, online, offline } @@ -206,7 +207,8 @@ final class NetworkConnectivityMonitor { Future> _unknownConnectivityCheck() async => const []; -final class ConnectivityAwareHttpClient extends http.BaseClient { +final class ConnectivityAwareHttpClient extends http.BaseClient + implements TerminatingHttpClient { ConnectivityAwareHttpClient({ required http.Client inner, required Future Function() requireNetwork, @@ -218,6 +220,29 @@ final class ConnectivityAwareHttpClient extends http.BaseClient { @override Future send(http.BaseRequest request) async { + await _requireNetworkBeforeDispatch(); + return _inner.send(request); + } + + @override + Future sendTerminating( + http.BaseRequest request, { + required Future terminate, + required Duration connectionTimeout, + }) async { + await _requireNetworkBeforeDispatch(); + final inner = _inner; + if (inner is TerminatingHttpClient) { + return inner.sendTerminating( + request, + terminate: terminate, + connectionTimeout: connectionTimeout, + ); + } + return inner.send(request); + } + + Future _requireNetworkBeforeDispatch() async { try { await _requireNetwork(); } on Object catch (error, stackTrace) { @@ -232,7 +257,6 @@ final class ConnectivityAwareHttpClient extends http.BaseClient { stackTrace, ); } - return _inner.send(request); } @override diff --git a/lib/src/features/sync/pending_ops_replayer.dart b/lib/src/features/sync/pending_ops_replayer.dart index be8fdea9..977ba95b 100644 --- a/lib/src/features/sync/pending_ops_replayer.dart +++ b/lib/src/features/sync/pending_ops_replayer.dart @@ -318,6 +318,15 @@ class PendingOpsReplayer { serverList.rawJson, ); for (final dependent in dependents) { + final dependentRequest = _request(dependent); + if (dependent.operation == 'delete_task_list' && + !dependentRequest.containsKey(_childTaskConflictBaselineKey) && + dependent.baselineUpdatedUtc != null) { + // A list mutation acknowledges list metadata only. Preserve the + // original cutoff used to detect independent child-task changes. + dependentRequest[_childTaskConflictBaselineKey] = + dependent.baselineUpdatedUtc; + } final baseline = _normalizeTaskListConflictSnapshot( _jsonObject(dependent.baselineRawJson ?? '{}'), ); @@ -328,6 +337,7 @@ class PendingOpsReplayer { _database.pendingOps, )..where((row) => row.id.equals(dependent.id))).write( PendingOpsCompanion( + requestJson: Value(jsonEncode(dependentRequest)), baselineRawJson: Value(jsonEncode(baseline)), baselineUpdatedUtc: serverList.updated == null ? const Value.absent() @@ -993,24 +1003,23 @@ class PendingOpsReplayer { return; } - final local = - await (_database.select(_database.tasks)..where( - (row) => - row.accountId.equals(_accountId) & - row.taskListId.equals(op.taskListId!) & - row.id.equals(op.taskId!), - )) - .getSingleOrNull(); - if (local == null) { - return; - } + final local = op.baselineRawJson == null + ? await (_database.select(_database.tasks)..where( + (row) => + row.accountId.equals(_accountId) & + row.taskListId.equals(op.taskListId!) & + row.id.equals(op.taskId!), + )) + .getSingleOrNull() + : null; + if (op.baselineRawJson == null && local == null) return; final current = await _apiClient.getTask( taskListId: op.taskListId!, taskId: op.taskId!, ); final baselineJson = op.baselineRawJson == null - ? _jsonObject(local.rawJson) + ? _jsonObject(local!.rawJson) : _jsonObject(op.baselineRawJson!); final normalizedBaseline = _normalizeTaskConflictSnapshot(baselineJson); final normalizedCurrent = _normalizeTaskConflictSnapshot(current.rawJson); @@ -1090,7 +1099,10 @@ class PendingOpsReplayer { PendingOp op, String action, ) async { - final baselineUpdatedUtc = _parseUtc(op.baselineUpdatedUtc); + final request = _request(op); + final childBaseline = request[_childTaskConflictBaselineKey]?.toString(); + final baselineUpdatedUtc = + _parseUtc(childBaseline) ?? _parseUtc(op.baselineUpdatedUtc); if (baselineUpdatedUtc == null || op.taskListId == null) { return; } @@ -1176,6 +1188,9 @@ class PendingOpsReplayer { String _now() => _nowUtc().toIso8601String(); } +const _childTaskConflictBaselineKey = + '_busymaxChildTaskConflictBaselineUpdatedUtc'; + const _pendingOpReferenceKeys = { 'id', 'parent', diff --git a/test/dav/http/dav_http_transport_test.dart b/test/dav/http/dav_http_transport_test.dart index bb942426..ea4cbce2 100644 --- a/test/dav/http/dav_http_transport_test.dart +++ b/test/dav/http/dav_http_transport_test.dart @@ -6,6 +6,7 @@ import 'dart:math'; import 'package:busymax/src/dav/dav_errors.dart'; import 'package:busymax/src/dav/dav_provider_profile.dart'; import 'package:busymax/src/dav/http/dav_http_transport.dart'; +import 'package:busymax/src/core/http/terminating_http_client.dart'; import 'package:busymax/src/providers/busy_provider.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:http/http.dart' as http; @@ -359,6 +360,7 @@ void main() { ), ), ); + await Future.delayed(Duration.zero); expect(client.calls, 1); expect(client.aborts, 1); }); @@ -494,11 +496,99 @@ void main() { expect(sibling.statusCode, 200); await timedOutExpectation; + await Future.delayed(Duration.zero); expect(token.isCancelled, isFalse); expect(client.calls, 2); expect(client.aborts, 1); }, ); + + test( + 'timeout terminates native connection establishment and releases permit', + () async { + final client = _ConnectionStageTerminatingClient(); + final transport = _nextcloudTransport( + client, + limits: const DavTransportLimits( + connectTimeout: Duration(seconds: 5), + operationTimeout: Duration(milliseconds: 25), + maximumReadAttempts: 1, + maximumConcurrentPerAccount: 1, + ), + ); + final elapsed = Stopwatch()..start(); + + await expectLater( + transport.send( + _propfind( + Uri.parse( + 'https://cloud.example.test/nextcloud/remote.php/dav/connect', + ), + ), + credential: credential, + ), + throwsA( + isA().having( + (error) => error.code, + 'code', + 'DavOperationTimeout', + ), + ), + ); + elapsed.stop(); + await Future.delayed(Duration.zero); + expect(elapsed.elapsed, lessThan(const Duration(seconds: 1))); + expect(client.connectionTerminations, 1); + expect(client.connectionActive, isFalse); + + final next = await transport.send( + _propfind( + Uri.parse('https://cloud.example.test/nextcloud/remote.php/dav/next'), + ), + credential: credential, + ); + expect(next.statusCode, 200); + expect(client.calls, 2); + }, + ); +} + +final class _ConnectionStageTerminatingClient extends http.BaseClient + implements TerminatingHttpClient { + var calls = 0; + var connectionTerminations = 0; + var connectionActive = false; + + @override + Future send(http.BaseRequest request) { + throw StateError('DAV must use sendTerminating for native requests.'); + } + + @override + Future sendTerminating( + http.BaseRequest request, { + required Future terminate, + required Duration connectionTimeout, + }) { + calls += 1; + if (calls == 1) { + connectionActive = true; + unawaited( + terminate.then((_) { + connectionTerminations += 1; + connectionActive = false; + }), + ); + // Models IOClient.openUrl before its request abort listener exists. The + // transport-level termination hook closes the isolated connection, but + // this deliberately never-completing future proves the caller does not + // wait without a bound for abort acknowledgement. + return Completer().future; + } + return Future.value( + http.StreamedResponse(Stream.value(utf8.encode('ok')), 200), + ); + } } final class _AbortObservingClient extends http.BaseClient { diff --git a/test/features/connectivity/network_connectivity_service_test.dart b/test/features/connectivity/network_connectivity_service_test.dart index ef3818bf..a94121e0 100644 --- a/test/features/connectivity/network_connectivity_service_test.dart +++ b/test/features/connectivity/network_connectivity_service_test.dart @@ -1,5 +1,6 @@ import 'dart:async'; +import 'package:busymax/src/core/http/terminating_http_client.dart'; import 'package:busymax/src/features/connectivity/network_connectivity_service.dart'; import 'package:busymax/src/features/sync/account_sync_operations.dart'; import 'package:connectivity_plus/connectivity_plus.dart'; @@ -180,6 +181,27 @@ void main() { }, ); + test('connectivity wrapper preserves terminating dispatch', () async { + final inner = _RecordingTerminatingHttpClient(); + final client = ConnectivityAwareHttpClient( + inner: inner, + requireNetwork: () async {}, + ); + addTearDown(client.close); + final terminate = Completer(); + + final response = await client.sendTerminating( + http.Request('GET', Uri.parse('https://example.test')), + terminate: terminate.future, + connectionTimeout: const Duration(seconds: 7), + ); + + expect(response.statusCode, 204); + expect(inner.terminatingCalls, 1); + expect(inner.connectionTimeout, const Duration(seconds: 7)); + expect(inner.terminate, same(terminate.future)); + }); + test( 'connectivity-aware account sync rejects every sync entry point', () async { @@ -220,6 +242,30 @@ final class _RecordingHttpClient extends http.BaseClient { } } +final class _RecordingTerminatingHttpClient extends http.BaseClient + implements TerminatingHttpClient { + var terminatingCalls = 0; + Duration? connectionTimeout; + Future? terminate; + + @override + Future send(http.BaseRequest request) async { + return http.StreamedResponse(const Stream>.empty(), 204); + } + + @override + Future sendTerminating( + http.BaseRequest request, { + required Future terminate, + required Duration connectionTimeout, + }) async { + terminatingCalls += 1; + this.terminate = terminate; + this.connectionTimeout = connectionTimeout; + return http.StreamedResponse(const Stream>.empty(), 204); + } +} + final class _RecordingAccountSyncOperations implements AccountSyncOperations { final calls = []; diff --git a/test/features/sync/pending_ops_replayer_test.dart b/test/features/sync/pending_ops_replayer_test.dart index e70744cc..5e600b1c 100644 --- a/test/features/sync/pending_ops_replayer_test.dart +++ b/test/features/sync/pending_ops_replayer_test.dart @@ -926,6 +926,92 @@ void main() { }, ); + test( + 'remote edit conflicts with a patch between two cross-list moves', + () async { + const baselineUpdated = '2026-06-04T00:00:00.000Z'; + final baselineRaw = jsonEncode({ + 'id': 'task-1', + 'title': 'Base title', + 'updated': baselineUpdated, + }); + for (final listId in ['list-2', 'list-3']) { + await database.taskListsDao.upsertTaskList(_taskList(listId)); + } + await database.tasksDao.upsertTask( + _task( + 'list-1', + 'task-1', + title: 'Base title', + updatedUtc: baselineUpdated, + rawJson: baselineRaw, + ), + ); + apiClient + ..remoteTask = _taskDto( + 'task-1', + title: 'Base title', + updated: DateTime.parse(baselineUpdated), + ) + ..remoteTaskAfterMove = _taskDto( + 'task-1', + title: 'Independent remote title', + updated: DateTime.utc(2026, 6, 4, 0, 10), + ); + final repository = TasksRepository( + database: database, + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 6, 4), + ); + await repository.moveTask( + const TaskMoveInput( + sourceTaskListId: 'list-1', + taskId: 'task-1', + destinationTaskListId: 'list-2', + ), + ); + await repository.patchTask( + 'list-2', + 'task-1', + const TaskPatchInput({'title': 'Local title'}), + ); + await repository.moveTask( + const TaskMoveInput( + sourceTaskListId: 'list-2', + taskId: 'task-1', + destinationTaskListId: 'list-3', + ), + ); + + expect( + await PendingOpsReplayer( + database: database, + apiClient: apiClient, + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 6, 4, 1), + ).replayDueOps(), + 1, + ); + + expect(apiClient.calls, ['move_task:task-1']); + final pending = await database.select(database.pendingOps).get(); + final patch = pending.singleWhere( + (operation) => operation.operation == 'patch_task', + ); + final laterMove = pending.singleWhere( + (operation) => operation.operation == 'move_task', + ); + expect(patch.lastErrorCode, 'conflict'); + expect(patch.lastErrorMessage, contains('title')); + expect(laterMove.dependsOnOpId, patch.id); + expect( + (await database.tasksDao.listTasks('account', 'list-3')).single.title, + 'Local title', + ); + expect(apiClient.remoteTask!.title, 'Independent remote title'); + }, + ); + test( 'delete remains blocked behind a permanently rejected cross-list move', () async { @@ -1177,6 +1263,77 @@ void main() { expect(local.localDirty, isFalse); }); + for (final childChanged in [false, true]) { + test( + 'rename then delete ${childChanged ? 'blocks' : 'succeeds'} for child cutoff', + () async { + final baseline = DateTime.utc(2026, 6, 4); + final childEdit = baseline.add(const Duration(minutes: 5)); + final renameAcknowledged = baseline.add(const Duration(minutes: 10)); + await database.taskListsDao.upsertTaskList( + _taskList( + 'list-1', + title: 'A', + updatedUtc: baseline.toIso8601String(), + ), + ); + apiClient + ..persistTaskListPatches = true + ..remoteTaskList = _taskListDto( + 'list-1', + title: 'A', + updated: baseline, + ) + ..taskListPatchResultUpdated = renameAcknowledged + ..remoteTasksPage = TasksPageDto( + items: childChanged + ? [_taskDto('child-1', updated: childEdit)] + : const [], + rawJson: const {}, + ); + final repository = TaskListsRepository( + database: database, + accountId: 'account', + nowUtc: () => baseline, + ); + await repository.renameTaskList('list-1', 'B'); + await repository.deleteTaskList('list-1'); + + final applied = await PendingOpsReplayer( + database: database, + apiClient: apiClient, + accountId: 'account', + nowUtc: () => baseline.add(const Duration(hours: 1)), + ).replayDueOps(); + + expect(apiClient.taskListPageUpdatedMins, [baseline]); + expect( + apiClient.calls.where((call) => call == 'delete_task_list:list-1'), + hasLength(childChanged ? 0 : 1), + ); + expect(applied, childChanged ? 1 : 2); + if (childChanged) { + final delete = (await database.select(database.pendingOps).get()) + .singleWhere( + (operation) => operation.operation == 'delete_task_list', + ); + expect(delete.lastErrorCode, 'conflict'); + expect( + delete.baselineUpdatedUtc, + renameAcknowledged.toIso8601String(), + ); + expect( + jsonDecode(delete.requestJson), + containsPair( + '_busymaxChildTaskConflictBaselineUpdatedUtc', + baseline.toIso8601String(), + ), + ); + } + }, + ); + } + for (final reminderValue in [false, true]) { test( 'list creation preserves in-flight reminder value $reminderValue and rename', @@ -2498,6 +2655,8 @@ class _FakeTaskRemoteClient implements TaskRemoteClient { TaskListDto? remoteTaskList; TaskDto? remoteTask; TaskDto? remoteTaskAfterMove; + DateTime? taskListPatchResultUpdated; + final taskListPageUpdatedMins = []; Completer? createTaskGate; Completer? createTaskListStarted; Completer? createTaskListGate; @@ -2536,7 +2695,11 @@ class _FakeTaskRemoteClient implements TaskRemoteClient { taskListPatchStarted!.complete(); } await taskListPatchGate?.future; - final result = _taskListDto(taskListId, title: title); + final result = _taskListDto( + taskListId, + title: title, + updated: taskListPatchResultUpdated, + ); if (persistTaskListPatches) remoteTaskList = result; return result; } @@ -2684,6 +2847,7 @@ class _FakeTaskRemoteClient implements TaskRemoteClient { DateTime? updatedMin, bool showAssigned = false, }) async { + taskListPageUpdatedMins.add(updatedMin); return remoteTasksPage; } } From fc389d0770358297c0615e4d493bd274a3c1da41 Mon Sep 17 00:00:00 2001 From: albert Date: Sat, 19 Sep 2026 09:31:17 -0700 Subject: [PATCH 21/25] Implemented the complete first-day-of-week correction and regression coverage --- .github/workflows/flutter-linux.yml | 11 + lib/l10n/app_en.arb | 4 +- lib/l10n/generated/app_localizations.dart | 4 +- lib/l10n/generated/app_localizations_en.dart | 4 +- lib/l10n/generated/app_localizations_pt.dart | 4 +- .../platform/linux_first_weekday_source.dart | 19 +- linux/runner/CMakeLists.txt | 1 + linux/runner/first_weekday_preference.cc | 386 ++++++++++++++++ linux/runner/first_weekday_preference.h | 49 ++ linux/runner/my_application.cc | 71 ++- .../tests/first_weekday_preference_test.cc | 425 ++++++++++++++++++ .../org.gnome.desktop.calendar.gschema.xml | 8 + .../BusymaxAndroidPlatformPlugin.kt | 98 +++- .../BusymaxAndroidPlatformPluginTest.kt | 62 ++- test/app/first_day_of_week_test.dart | 4 +- .../presentation/settings_screen_test.dart | 2 +- test/flutter_test_config.dart | 1 + .../android/android_date_picker_test.dart | 145 ++++++ .../android_first_weekday_source_test.dart | 63 +++ .../linux_first_weekday_source_test.dart | 97 ++++ .../windows_clock_preference_test.dart | 33 ++ 21 files changed, 1455 insertions(+), 36 deletions(-) create mode 100644 linux/runner/first_weekday_preference.cc create mode 100644 linux/runner/first_weekday_preference.h create mode 100644 linux/runner/tests/first_weekday_preference_test.cc create mode 100644 linux/runner/tests/schemas/org.gnome.desktop.calendar.gschema.xml create mode 100644 test/platform/android_first_weekday_source_test.dart create mode 100644 test/platform/linux_first_weekday_source_test.dart diff --git a/.github/workflows/flutter-linux.yml b/.github/workflows/flutter-linux.yml index 3ce4efad..b59d8bd6 100644 --- a/.github/workflows/flutter-linux.yml +++ b/.github/workflows/flutter-linux.yml @@ -100,6 +100,17 @@ jobs: linux/runner/tests/first_weekday_test.cc \ -o build/linux/native-tests/first_weekday_test build/linux/native-tests/first_weekday_test + mkdir -p build/linux/native-tests/weekday-schemas + glib-compile-schemas --strict \ + --targetdir=build/linux/native-tests/weekday-schemas \ + linux/runner/tests/schemas + g++ -std=c++17 -Wall -Wextra -Werror \ + linux/runner/tests/first_weekday_preference_test.cc \ + linux/runner/first_weekday_preference.cc \ + -o build/linux/native-tests/first_weekday_preference_test \ + $(pkg-config --cflags --libs gio-2.0) + build/linux/native-tests/first_weekday_preference_test \ + build/linux/native-tests/weekday-schemas LC_ALL=C.UTF-8 MALLOC_PERTURB_=165 \ build/linux/native-tests/first_weekday_test g++ -std=c++17 -Wall -Wextra -Werror \ diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index a0b53c87..a44eabbf 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -2,8 +2,8 @@ "launchAtLoginManagedExternally": "BusyMax was launched by your desktop's startup settings. Remove its entry there, then quit and reopen BusyMax to use this switch.", "timeFormat": "Time format", "firstDayOfWeek": "First day of the week", - "systemDefault": "System default", - "systemDefaultResolved": "System default ({weekday})", + "systemDefault": "System Default", + "systemDefaultResolved": "System Default ({weekday})", "@systemDefaultResolved": {"placeholders": {"weekday": {"type": "String"}}}, "timeFormatTwelveHour": "12-hour", "timeFormatTwentyFourHour": "24-hour", diff --git a/lib/l10n/generated/app_localizations.dart b/lib/l10n/generated/app_localizations.dart index 971aef93..f98fc773 100644 --- a/lib/l10n/generated/app_localizations.dart +++ b/lib/l10n/generated/app_localizations.dart @@ -156,13 +156,13 @@ abstract class AppLocalizations { /// No description provided for @systemDefault. /// /// In en, this message translates to: - /// **'System default'** + /// **'System Default'** String get systemDefault; /// No description provided for @systemDefaultResolved. /// /// In en, this message translates to: - /// **'System default ({weekday})'** + /// **'System Default ({weekday})'** String systemDefaultResolved(String weekday); /// No description provided for @timeFormatTwelveHour. diff --git a/lib/l10n/generated/app_localizations_en.dart b/lib/l10n/generated/app_localizations_en.dart index 7f488488..429ac2b8 100644 --- a/lib/l10n/generated/app_localizations_en.dart +++ b/lib/l10n/generated/app_localizations_en.dart @@ -23,11 +23,11 @@ class AppLocalizationsEn extends AppLocalizations { String get firstDayOfWeek => 'First day of the week'; @override - String get systemDefault => 'System default'; + String get systemDefault => 'System Default'; @override String systemDefaultResolved(String weekday) { - return 'System default ($weekday)'; + return 'System Default ($weekday)'; } @override diff --git a/lib/l10n/generated/app_localizations_pt.dart b/lib/l10n/generated/app_localizations_pt.dart index 9a8b698b..437533b5 100644 --- a/lib/l10n/generated/app_localizations_pt.dart +++ b/lib/l10n/generated/app_localizations_pt.dart @@ -23,11 +23,11 @@ class AppLocalizationsPt extends AppLocalizations { String get firstDayOfWeek => 'First day of the week'; @override - String get systemDefault => 'System default'; + String get systemDefault => 'System Default'; @override String systemDefaultResolved(String weekday) { - return 'System default ($weekday)'; + return 'System Default ($weekday)'; } @override diff --git a/lib/src/platform/linux_first_weekday_source.dart b/lib/src/platform/linux_first_weekday_source.dart index 524b04ea..df866053 100644 --- a/lib/src/platform/linux_first_weekday_source.dart +++ b/lib/src/platform/linux_first_weekday_source.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/services.dart'; import '../l10n/week_preferences_scope.dart'; @@ -11,9 +13,17 @@ final class LinuxFirstWeekdaySource MethodChannel channel = const MethodChannel( 'io.busystack.busymax/gtk_settings', ), - }) : _channel = channel; + EventChannel events = const EventChannel( + 'io.busystack.busymax/first_weekday', + ), + }) : _channel = channel, + _events = events; final MethodChannel _channel; + final EventChannel _events; + + @override + Stream get changes => _events.receiveBroadcastStream().map((_) {}); @override Future read() async { @@ -25,4 +35,11 @@ final class LinuxFirstWeekdaySource return null; } } + + @override + void dispose() { + unawaited( + _channel.invokeMethod('cancelFirstWeekdayReads').catchError((_) {}), + ); + } } diff --git a/linux/runner/CMakeLists.txt b/linux/runner/CMakeLists.txt index ccbb420f..9463469c 100644 --- a/linux/runner/CMakeLists.txt +++ b/linux/runner/CMakeLists.txt @@ -7,6 +7,7 @@ project(runner LANGUAGES CXX) # # Any new source files that you add to the application should be added here. add_executable(${BINARY_NAME} + "first_weekday_preference.cc" "main.cc" "my_application.cc" "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" diff --git a/linux/runner/first_weekday_preference.cc b/linux/runner/first_weekday_preference.cc new file mode 100644 index 00000000..93330d86 --- /dev/null +++ b/linux/runner/first_weekday_preference.cc @@ -0,0 +1,386 @@ +#include "first_weekday_preference.h" + +#include "first_weekday.h" + +#include +#include + +namespace { + +constexpr char kPortalName[] = "org.freedesktop.portal.Desktop"; +constexpr char kPortalPath[] = "/org/freedesktop/portal/desktop"; +constexpr char kPortalInterface[] = "org.freedesktop.portal.Settings"; +constexpr char kPortalReadOne[] = "ReadOne"; +constexpr char kPortalChanged[] = "SettingChanged"; +constexpr char kCalendarNamespace[] = "org.gnome.desktop.calendar"; +constexpr char kWeekStartKey[] = "week-start-day"; + +enum class OverrideKind { kExplicit, kDefault, kInvalid }; + +struct OverrideValue { + OverrideKind kind = OverrideKind::kInvalid; + int weekday = 0; +}; + +OverrideValue ParseOverride(const gchar* value) { + if (value == nullptr) return {}; + if (g_str_equal(value, "default")) { + return {OverrideKind::kDefault, 0}; + } + constexpr const char* kWeekdays[] = { + "monday", "tuesday", "wednesday", "thursday", + "friday", "saturday", "sunday", + }; + for (int index = 0; index < 7; ++index) { + if (g_str_equal(value, kWeekdays[index])) { + return {OverrideKind::kExplicit, index + 1}; + } + } + return {}; +} + +OverrideValue ParseStringVariant(GVariant* value) { + if (value == nullptr || !g_variant_is_of_type(value, G_VARIANT_TYPE_STRING)) { + return {}; + } + return ParseOverride(g_variant_get_string(value, nullptr)); +} + +OverrideValue ParsePortalReply(GVariant* reply) { + if (reply == nullptr || !g_variant_is_of_type(reply, G_VARIANT_TYPE_TUPLE) || + g_variant_n_children(reply) != 1) { + return {}; + } + g_autoptr(GVariant) wrapped = g_variant_get_child_value(reply, 0); + if (!g_variant_is_of_type(wrapped, G_VARIANT_TYPE_VARIANT)) return {}; + g_autoptr(GVariant) value = g_variant_get_variant(wrapped); + return ParseStringVariant(value); +} + +} // namespace + +struct BusyMaxLinuxFirstWeekdayPreference::State + : public std::enable_shared_from_this { + struct Operation { + explicit Operation(ReadCallback value) + : callback(std::move(value)), cancellable(g_cancellable_new()) {} + ~Operation() { g_clear_object(&cancellable); } + + ReadCallback callback; + GCancellable* cancellable; + guint timeout_id = 0; + bool completed = false; + }; + + struct AsyncContext { + std::shared_ptr state; + std::shared_ptr operation; + }; + + explicit State(Configuration configuration) + : direct_schema(configuration.direct_schema), + direct_key(configuration.direct_key), + locale_reader(configuration.locale_reader + ? std::move(configuration.locale_reader) + : LocaleReader(BusyMaxReadFirstWeekday)), + portal_timeout_milliseconds( + configuration.portal_timeout_milliseconds) { + if (configuration.portal_connection != nullptr) { + portal_connection = G_DBUS_CONNECTION( + g_object_ref(configuration.portal_connection)); + } + if (configuration.schema_source != nullptr) { + schema_source = + g_settings_schema_source_ref(configuration.schema_source); + } + } + + ~State() { + DisconnectWatchers(); + g_clear_object(&direct_settings); + g_clear_object(&portal_connection); + if (schema_source != nullptr) { + g_settings_schema_source_unref(schema_source); + schema_source = nullptr; + } + } + + static void DeleteAsyncContext(gpointer data) { + delete static_cast(data); + } + + void Dispose() { + if (disposed) return; + disposed = true; + change_callback = nullptr; + DisconnectWatchers(); + CancelActive(false); + } + + void Read(ReadCallback callback) { + if (disposed) return; + CancelActive(true); + auto operation = std::make_shared(std::move(callback)); + active = operation; + auto* timeout_context = new AsyncContext{shared_from_this(), operation}; + operation->timeout_id = g_timeout_add_full( + G_PRIORITY_DEFAULT, portal_timeout_milliseconds, PortalTimeout, + timeout_context, DeleteAsyncContext); + if (portal_connection != nullptr) { + SubscribePortal(); + CallPortal(operation); + return; + } + + auto* context = new AsyncContext{shared_from_this(), operation}; + g_bus_get(G_BUS_TYPE_SESSION, operation->cancellable, BusReady, context); + } + + void CancelActive(bool complete) { + const auto operation = active; + if (!operation || operation->completed) return; + active.reset(); + operation->completed = true; + if (operation->timeout_id != 0) { + g_source_remove(operation->timeout_id); + operation->timeout_id = 0; + } + g_cancellable_cancel(operation->cancellable); + auto callback = std::move(operation->callback); + if (complete && !disposed && callback) callback(std::nullopt); + } + + void Complete(const std::shared_ptr& operation, + std::optional value) { + if (operation->completed) return; + operation->completed = true; + if (operation->timeout_id != 0) { + g_source_remove(operation->timeout_id); + operation->timeout_id = 0; + } + if (active == operation) active.reset(); + auto callback = std::move(operation->callback); + if (!disposed && callback) callback(value); + } + + std::optional ReadLocale() const { + return locale_reader ? locale_reader() : std::nullopt; + } + + void CompleteOverride(const std::shared_ptr& operation, + OverrideValue override) { + if (override.kind == OverrideKind::kExplicit) { + Complete(operation, override.weekday); + } else { + // Both GNOME's recognized `default` and malformed successful portal + // replies fall back to the locale. A stale sandbox-local value must not + // supersede a successful host result. + Complete(operation, ReadLocale()); + } + } + + void ReadDirect(const std::shared_ptr& operation) { + if (disposed || operation->completed) return; + if (direct_settings == nullptr) { + GSettingsSchemaSource* source = schema_source != nullptr + ? schema_source + : g_settings_schema_source_get_default(); + if (source == nullptr) { + Complete(operation, ReadLocale()); + return; + } + GSettingsSchema* schema = g_settings_schema_source_lookup( + source, direct_schema.c_str(), TRUE); + if (schema == nullptr) { + Complete(operation, ReadLocale()); + return; + } + if (!g_settings_schema_has_key(schema, direct_key.c_str())) { + g_settings_schema_unref(schema); + Complete(operation, ReadLocale()); + return; + } + direct_settings = g_settings_new_full(schema, nullptr, nullptr); + g_settings_schema_unref(schema); + } + ConnectDirect(); + // GLib only guarantees later changed signals for keys read while a + // handler is connected, so the connection above intentionally precedes + // this initial value read. + g_autoptr(GVariant) value = + g_settings_get_value(direct_settings, direct_key.c_str()); + CompleteOverride(operation, ParseStringVariant(value)); + } + + void CallPortal(const std::shared_ptr& operation) { + if (disposed || operation->completed || portal_connection == nullptr) { + return; + } + auto* context = new AsyncContext{shared_from_this(), operation}; + g_dbus_connection_call( + portal_connection, kPortalName, kPortalPath, kPortalInterface, + kPortalReadOne, g_variant_new("(ss)", kCalendarNamespace, + kWeekStartKey), + G_VARIANT_TYPE("(v)"), G_DBUS_CALL_FLAGS_NONE, + static_cast(portal_timeout_milliseconds), + operation->cancellable, PortalRead, context); + } + + static void BusReady(GObject*, GAsyncResult* result, + gpointer user_data) { + std::unique_ptr context( + static_cast(user_data)); + auto& state = context->state; + auto& operation = context->operation; + g_autoptr(GError) error = nullptr; + GDBusConnection* connection = g_bus_get_finish(result, &error); + if (operation->completed || state->disposed) { + if (connection != nullptr) g_object_unref(connection); + return; + } + if (connection == nullptr) { + state->ReadDirect(operation); + return; + } + state->portal_connection = connection; + state->SubscribePortal(); + state->CallPortal(operation); + } + + static void PortalRead(GObject* source, GAsyncResult* result, + gpointer user_data) { + std::unique_ptr context( + static_cast(user_data)); + auto& state = context->state; + auto& operation = context->operation; + g_autoptr(GError) error = nullptr; + g_autoptr(GVariant) reply = g_dbus_connection_call_finish( + G_DBUS_CONNECTION(source), result, &error); + if (operation->completed || state->disposed) return; + if (reply == nullptr) { + state->ReadDirect(operation); + return; + } + state->CompleteOverride(operation, ParsePortalReply(reply)); + } + + static gboolean PortalTimeout(gpointer user_data) { + auto* context = static_cast(user_data); + auto& state = context->state; + auto& operation = context->operation; + operation->timeout_id = 0; + if (!operation->completed && !state->disposed) { + state->ReadDirect(operation); + g_cancellable_cancel(operation->cancellable); + } + return G_SOURCE_REMOVE; + } + + void StartWatching(ChangeCallback callback) { + if (disposed) return; + StopWatching(); + change_callback = std::move(callback); + SubscribePortal(); + if (direct_settings != nullptr) ConnectDirect(); + } + + void StopWatching() { + change_callback = nullptr; + DisconnectWatchers(); + } + + void ConnectDirect() { + if (direct_settings == nullptr || direct_signal_id != 0) return; + g_autofree gchar* detailed = + g_strdup_printf("changed::%s", direct_key.c_str()); + direct_signal_id = g_signal_connect(direct_settings, detailed, + G_CALLBACK(DirectChanged), this); + } + + void SubscribePortal() { + if (portal_connection == nullptr || !change_callback || + portal_subscription_id != 0) { + return; + } + portal_subscription_id = g_dbus_connection_signal_subscribe( + portal_connection, kPortalName, kPortalInterface, kPortalChanged, + kPortalPath, kCalendarNamespace, G_DBUS_SIGNAL_FLAGS_NONE, + PortalChanged, this, nullptr); + } + + void DisconnectWatchers() { + if (direct_settings != nullptr && direct_signal_id != 0) { + g_signal_handler_disconnect(direct_settings, direct_signal_id); + direct_signal_id = 0; + } + if (portal_connection != nullptr && portal_subscription_id != 0) { + g_dbus_connection_signal_unsubscribe(portal_connection, + portal_subscription_id); + portal_subscription_id = 0; + } + } + + static void DirectChanged(GSettings*, gchar*, gpointer user_data) { + auto* state = static_cast(user_data); + if (!state->disposed && state->change_callback) state->change_callback(); + } + + static void PortalChanged(GDBusConnection*, const gchar*, const gchar*, + const gchar*, const gchar*, GVariant* parameters, + gpointer user_data) { + auto* state = static_cast(user_data); + if (state->disposed || !state->change_callback || parameters == nullptr || + !g_variant_is_of_type(parameters, G_VARIANT_TYPE("(ssv)"))) { + return; + } + const gchar* name_space = nullptr; + const gchar* key = nullptr; + g_autoptr(GVariant) value = nullptr; + g_variant_get(parameters, "(&s&sv)", &name_space, &key, &value); + if (g_str_equal(name_space, kCalendarNamespace) && + g_str_equal(key, kWeekStartKey)) { + state->change_callback(); + } + } + + bool disposed = false; + GDBusConnection* portal_connection = nullptr; + GSettingsSchemaSource* schema_source = nullptr; + GSettings* direct_settings = nullptr; + std::string direct_schema; + std::string direct_key; + LocaleReader locale_reader; + guint portal_timeout_milliseconds; + guint portal_subscription_id = 0; + gulong direct_signal_id = 0; + ChangeCallback change_callback; + std::shared_ptr active; +}; + +BusyMaxLinuxFirstWeekdayPreference::BusyMaxLinuxFirstWeekdayPreference() + : BusyMaxLinuxFirstWeekdayPreference(Configuration{}) {} + +BusyMaxLinuxFirstWeekdayPreference::BusyMaxLinuxFirstWeekdayPreference( + Configuration configuration) + : state_(std::make_shared(std::move(configuration))) {} + +BusyMaxLinuxFirstWeekdayPreference::~BusyMaxLinuxFirstWeekdayPreference() { + state_->Dispose(); +} + +void BusyMaxLinuxFirstWeekdayPreference::Read(ReadCallback callback) { + state_->Read(std::move(callback)); +} + +void BusyMaxLinuxFirstWeekdayPreference::CancelRead() { + state_->CancelActive(true); +} + +void BusyMaxLinuxFirstWeekdayPreference::StartWatching( + ChangeCallback callback) { + state_->StartWatching(std::move(callback)); +} + +void BusyMaxLinuxFirstWeekdayPreference::StopWatching() { + state_->StopWatching(); +} diff --git a/linux/runner/first_weekday_preference.h b/linux/runner/first_weekday_preference.h new file mode 100644 index 00000000..f589a364 --- /dev/null +++ b/linux/runner/first_weekday_preference.h @@ -0,0 +1,49 @@ +#ifndef BUSYMAX_FIRST_WEEKDAY_PREFERENCE_H_ +#define BUSYMAX_FIRST_WEEKDAY_PREFERENCE_H_ + +#include + +#include +#include +#include + +// Resolves GNOME's explicit calendar preference before the effective LC_TIME +// convention. Portal access is attempted first so strict packages observe the +// host setting even when their local schema set is incomplete. +class BusyMaxLinuxFirstWeekdayPreference { + public: + using ReadCallback = std::function)>; + using ChangeCallback = std::function; + using LocaleReader = std::function()>; + + struct Configuration { + // Tests may supply a private bus connection and isolated schema source. + // Production leaves both null to use the session bus and default source. + GDBusConnection* portal_connection = nullptr; + GSettingsSchemaSource* schema_source = nullptr; + const char* direct_schema = "org.gnome.desktop.calendar"; + const char* direct_key = "week-start-day"; + LocaleReader locale_reader; + guint portal_timeout_milliseconds = 1000; + }; + + BusyMaxLinuxFirstWeekdayPreference(); + explicit BusyMaxLinuxFirstWeekdayPreference(Configuration configuration); + ~BusyMaxLinuxFirstWeekdayPreference(); + + BusyMaxLinuxFirstWeekdayPreference( + const BusyMaxLinuxFirstWeekdayPreference&) = delete; + BusyMaxLinuxFirstWeekdayPreference& operator=( + const BusyMaxLinuxFirstWeekdayPreference&) = delete; + + void Read(ReadCallback callback); + void CancelRead(); + void StartWatching(ChangeCallback callback); + void StopWatching(); + + private: + struct State; + std::shared_ptr state_; +}; + +#endif // BUSYMAX_FIRST_WEEKDAY_PREFERENCE_H_ diff --git a/linux/runner/my_application.cc b/linux/runner/my_application.cc index 952ef369..5e57ed35 100644 --- a/linux/runner/my_application.cc +++ b/linux/runner/my_application.cc @@ -1,6 +1,6 @@ #include "time_picker.h" #include "my_application.h" -#include "first_weekday.h" +#include "first_weekday_preference.h" #include #include @@ -13,6 +13,7 @@ #include #include #include +#include #include "flutter/generated_plugin_registrant.h" constexpr char kApplicationDisplayName[] = "BusyMax"; @@ -31,6 +32,8 @@ constexpr char kGtkFontSettingsEventChannel[] = "io.busystack.busymax/gtk_font_settings"; constexpr char kGtkThemeColorsEventChannel[] = "io.busystack.busymax/gtk_theme_colors"; +constexpr char kFirstWeekdayEventChannel[] = + "io.busystack.busymax/first_weekday"; constexpr gint64 kHeaderBarStateSchemaVersion = 3; constexpr gint kHeaderButtonHeight = 34; constexpr gint kHeaderButtonSpacing = 6; @@ -113,11 +116,14 @@ struct _MyApplication { gboolean external_calendar_open_ready; FlEventChannel* gtk_font_settings_event_channel; FlEventChannel* gtk_theme_colors_event_channel; + FlEventChannel* first_weekday_event_channel; + BusyMaxLinuxFirstWeekdayPreference* first_weekday_preference; gulong gtk_font_settings_signal_id; gulong gtk_theme_name_signal_id; gulong gtk_theme_dark_signal_id; gboolean gtk_font_settings_listening; gboolean gtk_theme_colors_listening; + gboolean first_weekday_listening; GtkCssProvider* header_bar_css_provider; gchar* header_bar_window_background_color; gchar* header_bar_background_color; @@ -4591,16 +4597,25 @@ static void apply_gtk_theme_to_bootstrap_chrome(MyApplication* self) { static void gtk_settings_method_call_cb(FlMethodChannel* channel, FlMethodCall* method_call, gpointer user_data) { + MyApplication* self = MY_APPLICATION(user_data); const gchar* method = fl_method_call_get_name(method_call); FlValue* args = fl_method_call_get_args(method_call); if (strcmp(method, "getGtkFont") == 0) { g_autoptr(FlValue) result = get_gtk_font_settings(); fl_method_call_respond_success(method_call, result, nullptr); } else if (strcmp(method, "getFirstWeekday") == 0) { - const auto weekday = BusyMaxReadFirstWeekday(); - g_autoptr(FlValue) result = - weekday ? fl_value_new_int(*weekday) : nullptr; - fl_method_call_respond_success(method_call, result, nullptr); + auto pending = std::shared_ptr( + static_cast(g_object_ref(method_call)), + [](FlMethodCall* call) { g_object_unref(call); }); + self->first_weekday_preference->Read( + [pending](std::optional weekday) { + g_autoptr(FlValue) result = + weekday ? fl_value_new_int(*weekday) : nullptr; + fl_method_call_respond_success(pending.get(), result, nullptr); + }); + } else if (strcmp(method, "cancelFirstWeekdayReads") == 0) { + self->first_weekday_preference->CancelRead(); + fl_method_call_respond_success(method_call, nullptr, nullptr); } else if (strcmp(method, "getGtkThemeColors") == 0) { g_autoptr(FlValue) result = get_gtk_theme_colors(); fl_method_call_respond_success(method_call, result, nullptr); @@ -4612,6 +4627,37 @@ static void gtk_settings_method_call_cb(FlMethodChannel* channel, } } +static void send_first_weekday_event(MyApplication* self) { + if (!self->first_weekday_listening || + self->first_weekday_event_channel == nullptr) { + return; + } + g_autoptr(FlValue) event = fl_value_new_null(); + g_autoptr(GError) error = nullptr; + if (!fl_event_channel_send(self->first_weekday_event_channel, event, nullptr, + &error)) { + const gchar* message = error != nullptr ? error->message : "unknown error"; + g_warning("Failed to send first-weekday event: %s", message); + } +} + +static FlMethodErrorResponse* first_weekday_listen_cb( + FlEventChannel*, FlValue*, gpointer user_data) { + MyApplication* self = MY_APPLICATION(user_data); + self->first_weekday_listening = TRUE; + self->first_weekday_preference->StartWatching( + [self]() { send_first_weekday_event(self); }); + return nullptr; +} + +static FlMethodErrorResponse* first_weekday_cancel_cb( + FlEventChannel*, FlValue*, gpointer user_data) { + MyApplication* self = MY_APPLICATION(user_data); + self->first_weekday_listening = FALSE; + self->first_weekday_preference->StopWatching(); + return nullptr; +} + static void disconnect_gtk_font_settings_signal(MyApplication* self) { if (self->gtk_font_settings_signal_id == 0) { return; @@ -4754,6 +4800,14 @@ static void register_gtk_settings_channel(MyApplication* self, FlView* view) { fl_method_channel_set_method_call_handler( self->gtk_settings_channel, gtk_settings_method_call_cb, self, nullptr); + self->first_weekday_preference = + new BusyMaxLinuxFirstWeekdayPreference(); + self->first_weekday_event_channel = fl_event_channel_new( + messenger, kFirstWeekdayEventChannel, FL_METHOD_CODEC(codec)); + fl_event_channel_set_stream_handlers( + self->first_weekday_event_channel, first_weekday_listen_cb, + first_weekday_cancel_cb, self, nullptr); + self->gtk_font_settings_event_channel = fl_event_channel_new( messenger, kGtkFontSettingsEventChannel, FL_METHOD_CODEC(codec)); fl_event_channel_set_stream_handlers( @@ -5141,7 +5195,11 @@ static void my_application_dispose(GObject* object) { g_clear_object(&self->native_menu_channel); g_clear_object(&self->window_channel); g_clear_object(&self->header_bar_channel); + self->first_weekday_listening = FALSE; + delete self->first_weekday_preference; + self->first_weekday_preference = nullptr; g_clear_object(&self->gtk_settings_channel); + g_clear_object(&self->first_weekday_event_channel); g_clear_object(&self->external_calendar_open_channel); g_clear_object(&self->external_uri_launcher_channel); disconnect_gtk_font_settings_signal(self); @@ -5265,11 +5323,14 @@ static void my_application_init(MyApplication* self) { self->external_calendar_open_ready = FALSE; self->gtk_font_settings_event_channel = nullptr; self->gtk_theme_colors_event_channel = nullptr; + self->first_weekday_event_channel = nullptr; + self->first_weekday_preference = nullptr; self->gtk_font_settings_signal_id = 0; self->gtk_theme_name_signal_id = 0; self->gtk_theme_dark_signal_id = 0; self->gtk_font_settings_listening = FALSE; self->gtk_theme_colors_listening = FALSE; + self->first_weekday_listening = FALSE; self->hide_on_close = FALSE; self->suppress_header_bar_actions = FALSE; self->header_schedule_controls_visible = TRUE; diff --git a/linux/runner/tests/first_weekday_preference_test.cc b/linux/runner/tests/first_weekday_preference_test.cc new file mode 100644 index 00000000..5d25c41f --- /dev/null +++ b/linux/runner/tests/first_weekday_preference_test.cc @@ -0,0 +1,425 @@ +#include "../first_weekday_preference.h" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +constexpr char kPortalName[] = "org.freedesktop.portal.Desktop"; +constexpr char kPortalPath[] = "/org/freedesktop/portal/desktop"; +constexpr char kPortalInterface[] = "org.freedesktop.portal.Settings"; +constexpr char kCalendarNamespace[] = "org.gnome.desktop.calendar"; +constexpr char kWeekStartKey[] = "week-start-day"; + +int failures = 0; + +void Check(bool condition, const char* description) { + if (condition) return; + ++failures; + std::fprintf(stderr, "FAILED: %s\n", description); +} + +bool SpinUntil(const std::function& condition, + std::chrono::milliseconds timeout = + std::chrono::milliseconds(2000)) { + const auto deadline = std::chrono::steady_clock::now() + timeout; + while (!condition() && std::chrono::steady_clock::now() < deadline) { + while (g_main_context_iteration(nullptr, FALSE)) { + } + g_usleep(1000); + } + return condition(); +} + +std::optional Read(BusyMaxLinuxFirstWeekdayPreference& reader) { + bool complete = false; + std::optional result; + reader.Read([&](std::optional value) { + result = value; + complete = true; + }); + Check(SpinUntil([&] { return complete; }), "weekday read completes"); + return result; +} + +GDBusConnection* ConnectToBus(const gchar* address) { + g_autoptr(GError) error = nullptr; + GDBusConnection* connection = g_dbus_connection_new_for_address_sync( + address, + static_cast( + G_DBUS_CONNECTION_FLAGS_AUTHENTICATION_CLIENT | + G_DBUS_CONNECTION_FLAGS_MESSAGE_BUS_CONNECTION), + nullptr, nullptr, &error); + if (connection == nullptr) { + std::fprintf(stderr, "Private bus connection failed: %s\n", + error != nullptr ? error->message : "unknown error"); + std::abort(); + } + return connection; +} + +class PortalService { + public: + explicit PortalService(const gchar* address) + : service_(ConnectToBus(address)), client_(ConnectToBus(address)) { + g_autoptr(GError) error = nullptr; + introspection_ = g_dbus_node_info_new_for_xml( + "" + " " + " " + " " + " " + " " + " " + " " + " " + " " + " " + "", + &error); + if (introspection_ == nullptr) { + std::fprintf(stderr, "Portal introspection failed: %s\n", + error->message); + std::abort(); + } + } + + ~PortalService() { + SetAvailable(false); + for (auto* invocation : delayed_) { + g_dbus_method_invocation_return_dbus_error( + invocation, "org.freedesktop.portal.Error.Failed", + "Test portal stopped"); + } + delayed_.clear(); + g_dbus_node_info_unref(introspection_); + g_dbus_connection_flush_sync(service_, nullptr, nullptr); + g_dbus_connection_close_sync(client_, nullptr, nullptr); + g_dbus_connection_close_sync(service_, nullptr, nullptr); + g_object_unref(client_); + g_object_unref(service_); + } + + GDBusConnection* client() const { return client_; } + void set_value(std::string value) { value_ = std::move(value); } + void set_delay(bool value) { delay_ = value; } + + void SetAvailable(bool available) { + if (available == available_) return; + if (available) { + static const GDBusInterfaceVTable vtable = {HandleMethod, nullptr, + nullptr, {0}}; + g_autoptr(GError) error = nullptr; + registration_id_ = g_dbus_connection_register_object( + service_, kPortalPath, introspection_->interfaces[0], &vtable, this, + nullptr, &error); + Check(registration_id_ != 0, "portal object registers"); + g_autoptr(GVariant) reply = g_dbus_connection_call_sync( + service_, "org.freedesktop.DBus", "/org/freedesktop/DBus", + "org.freedesktop.DBus", "RequestName", + g_variant_new("(su)", kPortalName, 0U), G_VARIANT_TYPE("(u)"), + G_DBUS_CALL_FLAGS_NONE, 1000, nullptr, &error); + Check(reply != nullptr, "portal name is owned"); + available_ = reply != nullptr; + return; + } + if (registration_id_ != 0) { + g_dbus_connection_unregister_object(service_, registration_id_); + registration_id_ = 0; + } + g_autoptr(GError) error = nullptr; + g_dbus_connection_call_sync( + service_, "org.freedesktop.DBus", "/org/freedesktop/DBus", + "org.freedesktop.DBus", "ReleaseName", + g_variant_new("(s)", kPortalName), G_VARIANT_TYPE("(u)"), + G_DBUS_CALL_FLAGS_NONE, 1000, nullptr, &error); + available_ = false; + } + + void Emit(const char* name_space = kCalendarNamespace, + const char* key = kWeekStartKey) { + g_autoptr(GError) error = nullptr; + const gboolean sent = g_dbus_connection_emit_signal( + service_, nullptr, kPortalPath, kPortalInterface, "SettingChanged", + g_variant_new("(ssv)", name_space, key, + g_variant_new_string(value_.c_str())), + &error); + Check(sent, "portal setting signal emits"); + } + + void FailDelayed() { + const auto delayed = std::move(delayed_); + delayed_.clear(); + for (auto* invocation : delayed) { + g_dbus_method_invocation_return_dbus_error( + invocation, "org.freedesktop.portal.Error.Failed", + "Delayed test response"); + } + } + + int reads() const { return reads_; } + bool arguments_valid() const { return arguments_valid_; } + + private: + static void HandleMethod(GDBusConnection*, const gchar*, const gchar*, + const gchar*, const gchar* method, + GVariant* parameters, + GDBusMethodInvocation* invocation, + gpointer user_data) { + auto* self = static_cast(user_data); + if (!g_str_equal(method, "ReadOne")) { + g_dbus_method_invocation_return_dbus_error( + invocation, "org.freedesktop.DBus.Error.UnknownMethod", method); + return; + } + ++self->reads_; + const gchar* name_space = nullptr; + const gchar* key = nullptr; + g_variant_get(parameters, "(&s&s)", &name_space, &key); + self->arguments_valid_ = + self->arguments_valid_ && + g_str_equal(name_space, kCalendarNamespace) && + g_str_equal(key, kWeekStartKey); + if (self->delay_) { + self->delayed_.push_back( + G_DBUS_METHOD_INVOCATION(g_object_ref(invocation))); + return; + } + g_dbus_method_invocation_return_value( + invocation, + g_variant_new("(v)", g_variant_new_string(self->value_.c_str()))); + } + + GDBusConnection* service_; + GDBusConnection* client_; + GDBusNodeInfo* introspection_ = nullptr; + guint registration_id_ = 0; + bool available_ = false; + bool delay_ = false; + bool arguments_valid_ = true; + int reads_ = 0; + std::string value_ = "default"; + std::vector delayed_; +}; + +BusyMaxLinuxFirstWeekdayPreference::Configuration Configuration( + PortalService& portal, GSettingsSchemaSource* schemas, + std::optional locale = 7) { + BusyMaxLinuxFirstWeekdayPreference::Configuration configuration; + configuration.portal_connection = portal.client(); + configuration.schema_source = schemas; + configuration.locale_reader = [locale] { return locale; }; + configuration.portal_timeout_milliseconds = 100; + return configuration; +} + +GSettings* TestSettings(GSettingsSchemaSource* schemas) { + GSettingsSchema* schema = g_settings_schema_source_lookup( + schemas, kCalendarNamespace, TRUE); + Check(schema != nullptr, "test calendar schema exists"); + GSettings* settings = g_settings_new_full(schema, nullptr, nullptr); + g_settings_schema_unref(schema); + return settings; +} + +void SetSetting(GSettings* settings, const char* value) { + Check(g_settings_set_string(settings, kWeekStartKey, value), + "isolated setting changes"); + g_settings_sync(); + while (g_main_context_iteration(nullptr, FALSE)) { + } +} + +void TestPortalValues(PortalService& portal, + GSettingsSchemaSource* schemas) { + portal.SetAvailable(true); + BusyMaxLinuxFirstWeekdayPreference reader(Configuration(portal, schemas)); + const std::vector> weekdays = { + {"monday", 1}, {"tuesday", 2}, {"wednesday", 3}, + {"thursday", 4}, {"friday", 5}, {"saturday", 6}, + {"sunday", 7}, + }; + for (const auto& [value, expected] : weekdays) { + portal.set_value(value); + Check(Read(reader) == expected, "portal weekday maps to Dart numbering"); + } + portal.set_value("default"); + Check(Read(reader) == 7, "portal default uses locale convention"); + portal.set_value("not-a-weekday"); + Check(Read(reader) == 7, "malformed portal value uses locale convention"); + Check(portal.arguments_valid(), "portal receives exact namespace and key"); +} + +void TestPackagedPortalWins(PortalService& portal, + GSettingsSchemaSource* schemas) { + portal.SetAvailable(true); + portal.set_value("monday"); + auto configuration = Configuration(portal, schemas, 7); + configuration.direct_key = "key-not-in-local-schemas"; + BusyMaxLinuxFirstWeekdayPreference reader(std::move(configuration)); + Check(Read(reader) == 1, + "host portal Monday wins when package-local schema lacks key"); +} + +void TestDirectAndFallbacks(PortalService& portal, + GSettingsSchemaSource* schemas) { + portal.SetAvailable(false); + g_autoptr(GSettings) settings = TestSettings(schemas); + SetSetting(settings, "tuesday"); + BusyMaxLinuxFirstWeekdayPreference direct(Configuration(portal, schemas, 7)); + Check(Read(direct) == 2, "unavailable portal falls back to GSettings"); + + auto missing_schema = Configuration(portal, schemas, 4); + missing_schema.direct_schema = "org.gnome.desktop.missing"; + BusyMaxLinuxFirstWeekdayPreference schema_reader( + std::move(missing_schema)); + Check(Read(schema_reader) == 4, "missing schema safely uses locale"); + + auto missing_key = Configuration(portal, schemas, 5); + missing_key.direct_key = "missing-key"; + BusyMaxLinuxFirstWeekdayPreference key_reader(std::move(missing_key)); + Check(Read(key_reader) == 5, "missing key safely uses locale"); + + SetSetting(settings, "malformed"); + BusyMaxLinuxFirstWeekdayPreference malformed( + Configuration(portal, schemas, 6)); + Check(Read(malformed) == 6, "malformed direct value uses locale"); + SetSetting(settings, "default"); + Check(Read(malformed) == 6, "direct default uses valid locale fallback"); +} + +void TestDirectNotifications(PortalService& portal, + GSettingsSchemaSource* schemas) { + portal.SetAvailable(false); + g_autoptr(GSettings) settings = TestSettings(schemas); + SetSetting(settings, "monday"); + BusyMaxLinuxFirstWeekdayPreference reader(Configuration(portal, schemas, 6)); + int changes = 0; + reader.StartWatching([&] { ++changes; }); + Check(Read(reader) == 1, "direct watcher initial read sees Monday"); + + SetSetting(settings, "sunday"); + Check(SpinUntil([&] { return changes == 1; }), + "direct Monday to Sunday emits change"); + Check(Read(reader) == 7, "direct reread sees Sunday"); + + SetSetting(settings, "default"); + Check(SpinUntil([&] { return changes == 2; }), + "direct explicit to default emits change"); + Check(Read(reader) == 6, "direct default rereads locale"); + + reader.StopWatching(); + SetSetting(settings, "wednesday"); + Check(changes == 2, "stopped direct subscription emits nothing"); + reader.StartWatching([&] { ++changes; }); + Check(Read(reader) == 3, + "recreated direct subscription obtains current value"); +} + +void TestPortalNotifications(PortalService& portal, + GSettingsSchemaSource* schemas) { + portal.SetAvailable(true); + portal.set_value("monday"); + BusyMaxLinuxFirstWeekdayPreference reader(Configuration(portal, schemas)); + int changes = 0; + reader.StartWatching([&] { ++changes; }); + Check(Read(reader) == 1, "portal watcher initial read sees Monday"); + + portal.Emit(kCalendarNamespace, "unrelated-key"); + SpinUntil([&] { return false; }, std::chrono::milliseconds(20)); + Check(changes == 0, "portal watcher filters unrelated settings"); + portal.set_value("sunday"); + portal.Emit(); + Check(SpinUntil([&] { return changes == 1; }), + "portal Monday to Sunday emits change"); + Check(Read(reader) == 7, "portal reread sees Sunday"); + + reader.StopWatching(); + portal.set_value("wednesday"); + portal.Emit(); + SpinUntil([&] { return false; }, std::chrono::milliseconds(20)); + Check(changes == 1, "stopped portal subscription emits nothing"); + reader.StartWatching([&] { ++changes; }); + Check(Read(reader) == 3, + "recreated portal subscription obtains current value"); +} + +void TestBoundedFailureAndDisposal(PortalService& portal, + GSettingsSchemaSource* schemas) { + portal.SetAvailable(true); + g_autoptr(GSettings) settings = TestSettings(schemas); + SetSetting(settings, "wednesday"); + portal.set_delay(true); + auto bounded_configuration = Configuration(portal, schemas, 6); + bounded_configuration.portal_timeout_milliseconds = 20; + BusyMaxLinuxFirstWeekdayPreference bounded( + std::move(bounded_configuration)); + const auto started = std::chrono::steady_clock::now(); + Check(Read(bounded) == 3, + "bounded portal failure falls through to current direct value"); + const auto elapsed = std::chrono::steady_clock::now() - started; + Check(elapsed < std::chrono::milliseconds(500), + "portal failure remains inside controller timeout budget"); + portal.FailDelayed(); + while (g_main_context_iteration(nullptr, FALSE)) { + } + + int callbacks = 0; + const int reads_before_disposal = portal.reads(); + { + auto reader = std::make_unique( + Configuration(portal, schemas)); + reader->Read([&](std::optional) { ++callbacks; }); + Check(SpinUntil([&] { return portal.reads() > reads_before_disposal; }), + "delayed disposal read reaches portal"); + } + portal.FailDelayed(); + SpinUntil([&] { return false; }, std::chrono::milliseconds(20)); + Check(callbacks == 0, "disposed reader suppresses late callback"); + portal.set_delay(false); +} + +} // namespace + +int main(int argc, char** argv) { + if (argc != 2) { + std::fprintf(stderr, "Usage: %s COMPILED_SCHEMA_DIRECTORY\n", argv[0]); + return EXIT_FAILURE; + } + g_setenv("GSETTINGS_BACKEND", "memory", TRUE); + g_autoptr(GError) error = nullptr; + GSettingsSchemaSource* schemas = g_settings_schema_source_new_from_directory( + argv[1], nullptr, FALSE, &error); + if (schemas == nullptr) { + std::fprintf(stderr, "Test schema load failed: %s\n", error->message); + return EXIT_FAILURE; + } + + GTestDBus* bus = g_test_dbus_new(G_TEST_DBUS_NONE); + g_test_dbus_up(bus); + { + PortalService portal(g_test_dbus_get_bus_address(bus)); + TestPortalValues(portal, schemas); + TestPackagedPortalWins(portal, schemas); + TestDirectAndFallbacks(portal, schemas); + TestDirectNotifications(portal, schemas); + TestPortalNotifications(portal, schemas); + TestBoundedFailureAndDisposal(portal, schemas); + } + g_settings_schema_source_unref(schemas); + g_test_dbus_down(bus); + g_object_unref(bus); + + if (failures == 0) { + std::puts("BusyMax Linux first-weekday preference tests passed."); + } + return failures == 0 ? EXIT_SUCCESS : EXIT_FAILURE; +} diff --git a/linux/runner/tests/schemas/org.gnome.desktop.calendar.gschema.xml b/linux/runner/tests/schemas/org.gnome.desktop.calendar.gschema.xml new file mode 100644 index 00000000..105fab49 --- /dev/null +++ b/linux/runner/tests/schemas/org.gnome.desktop.calendar.gschema.xml @@ -0,0 +1,8 @@ + + + + + 'default' + + + diff --git a/packages/busymax_android_platform/android/src/main/kotlin/io/busystack/busymax_android_platform/BusymaxAndroidPlatformPlugin.kt b/packages/busymax_android_platform/android/src/main/kotlin/io/busystack/busymax_android_platform/BusymaxAndroidPlatformPlugin.kt index 7494ec23..027ba03d 100644 --- a/packages/busymax_android_platform/android/src/main/kotlin/io/busystack/busymax_android_platform/BusymaxAndroidPlatformPlugin.kt +++ b/packages/busymax_android_platform/android/src/main/kotlin/io/busystack/busymax_android_platform/BusymaxAndroidPlatformPlugin.kt @@ -109,6 +109,49 @@ internal fun dartWeekday(firstDay: String?): Int? = when (firstDay) { else -> null } +internal fun androidxFirstWeekday(): Int? = + dartWeekday(LocalePreferences.getFirstDayOfWeek(true)) + +internal class SystemSettingsReceiverLifecycle( + private val register: () -> Unit, + private val unregister: () -> Unit, + private val clearListener: () -> Unit, +) { + private var registered = false + + fun attach() { + if (registered) return + register() + registered = true + } + + fun detach() { + clearListener() + if (!registered) return + registered = false + unregister() + } +} + +internal class ActivityListenerLifecycle( + private val add: (T) -> Unit, + private val remove: (T) -> Unit, +) { + private var attached: T? = null + + fun attach(next: T) { + detach() + add(next) + attached = next + } + + fun detach() { + val current = attached ?: return + attached = null + remove(current) + } +} + /** Maps MSAL failures without turning transport or configuration faults into logout state. */ internal fun classifyMicrosoftSilentFailure(error: Throwable): MicrosoftSilentFailure = when (error) { is MicrosoftAccountMissingException, @@ -176,7 +219,6 @@ class BusymaxAndroidPlatformPlugin : FlutterPlugin, private lateinit var channel: MethodChannel private lateinit var eventChannel: EventChannel private var activity: Activity? = null - private var activityBinding: ActivityPluginBinding? = null private var eventSink: EventChannel.EventSink? = null private val main by lazy { Handler(Looper.getMainLooper()) } private val executor = Executors.newCachedThreadPool() @@ -193,6 +235,39 @@ class BusymaxAndroidPlatformPlugin : FlutterPlugin, eventSink?.success(mapOf("kind" to "systemSettingsChanged")) } } + private val settingsReceiverLifecycle by lazy { + SystemSettingsReceiverLifecycle( + register = { + val filter = IntentFilter().apply { + addAction(Intent.ACTION_TIME_CHANGED) + addAction(Intent.ACTION_TIMEZONE_CHANGED) + addAction(Intent.ACTION_LOCALE_CHANGED) + } + ContextCompat.registerReceiver( + context, + settingsReceiver, + filter, + ContextCompat.RECEIVER_NOT_EXPORTED, + ) + }, + unregister = { + try { context.unregisterReceiver(settingsReceiver) } catch (_: Exception) {} + }, + clearListener = { eventSink = null }, + ) + } + private val activityListenerLifecycle = ActivityListenerLifecycle( + add = { binding -> + binding.addActivityResultListener(this) + binding.addOnNewIntentListener(this) + binding.addRequestPermissionsResultListener(this) + }, + remove = { binding -> + binding.removeActivityResultListener(this) + binding.removeOnNewIntentListener(this) + binding.removeRequestPermissionsResultListener(this) + }, + ) override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) { detached = false @@ -202,12 +277,7 @@ class BusymaxAndroidPlatformPlugin : FlutterPlugin, channel.setMethodCallHandler(this) eventChannel.setStreamHandler(this) activePlugins.add(this) - val filter = IntentFilter().apply { - addAction(Intent.ACTION_TIME_CHANGED) - addAction(Intent.ACTION_TIMEZONE_CHANGED) - addAction(Intent.ACTION_LOCALE_CHANGED) - } - ContextCompat.registerReceiver(context, settingsReceiver, filter, ContextCompat.RECEIVER_NOT_EXPORTED) + settingsReceiverLifecycle.attach() } override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) { @@ -216,7 +286,7 @@ class BusymaxAndroidPlatformPlugin : FlutterPlugin, activePlugins.remove(this) channel.setMethodCallHandler(null) eventChannel.setStreamHandler(null) - try { context.unregisterReceiver(settingsReceiver) } catch (_: Exception) {} + settingsReceiverLifecycle.detach() executor.shutdown() } @@ -226,11 +296,8 @@ class BusymaxAndroidPlatformPlugin : FlutterPlugin, override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) = attachActivity(binding) private fun attachActivity(binding: ActivityPluginBinding) { - activityBinding = binding + activityListenerLifecycle.attach(binding) activity = binding.activity - binding.addActivityResultListener(this) - binding.addOnNewIntentListener(this) - binding.addRequestPermissionsResultListener(this) captureActivation(binding.activity.intent) } @@ -238,10 +305,7 @@ class BusymaxAndroidPlatformPlugin : FlutterPlugin, override fun onDetachedFromActivity() = detachActivity() private fun detachActivity() { - activityBinding?.removeActivityResultListener(this) - activityBinding?.removeOnNewIntentListener(this) - activityBinding?.removeRequestPermissionsResultListener(this) - activityBinding = null + activityListenerLifecycle.detach() activity = null } @@ -299,7 +363,7 @@ class BusymaxAndroidPlatformPlugin : FlutterPlugin, } private fun firstWeekday(): Int? = - dartWeekday(LocalePreferences.getFirstDayOfWeek(true)) + androidxFirstWeekday() private fun authorizeGoogle(call: MethodCall, result: MethodChannel.Result, interactive: Boolean) { val scopes = stringList(call.argument>("scopes")) diff --git a/packages/busymax_android_platform/android/src/test/kotlin/io/busystack/busymax_android_platform/BusymaxAndroidPlatformPluginTest.kt b/packages/busymax_android_platform/android/src/test/kotlin/io/busystack/busymax_android_platform/BusymaxAndroidPlatformPluginTest.kt index b5dd59fc..73fe204d 100644 --- a/packages/busymax_android_platform/android/src/test/kotlin/io/busystack/busymax_android_platform/BusymaxAndroidPlatformPluginTest.kt +++ b/packages/busymax_android_platform/android/src/test/kotlin/io/busystack/busymax_android_platform/BusymaxAndroidPlatformPluginTest.kt @@ -1,11 +1,12 @@ package io.busystack.busymax_android_platform -import io.flutter.plugin.common.MethodCall -import io.flutter.plugin.common.MethodChannel import androidx.core.text.util.LocalePreferences import com.microsoft.identity.client.exception.MsalClientException import com.microsoft.identity.client.exception.MsalServiceException import com.microsoft.identity.client.exception.MsalUiRequiredException +import io.flutter.plugin.common.MethodCall +import io.flutter.plugin.common.MethodChannel +import java.util.Locale import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertNotNull import org.junit.jupiter.api.Test @@ -30,6 +31,63 @@ internal class BusymaxAndroidPlatformPluginTest { assertEquals(null, dartWeekday("unexpected")) } + @Test + fun firstWeekday_readsUnicodeOverrideThroughAndroidx() { + val original = Locale.getDefault() + try { + Locale.setDefault(Locale.forLanguageTag("en-US-u-fw-mon")) + assertEquals(1, androidxFirstWeekday()) + + Locale.setDefault(Locale.forLanguageTag("en-US-u-fw-thu")) + assertEquals(4, androidxFirstWeekday()) + } finally { + Locale.setDefault(original) + } + } + + @Test + fun systemSettingsReceiver_detachesAndReattachesWithoutDuplicatesOrStaleListener() { + var registrations = 0 + var unregistrations = 0 + var listenerActive = true + val lifecycle = SystemSettingsReceiverLifecycle( + register = { registrations++ }, + unregister = { unregistrations++ }, + clearListener = { listenerActive = false }, + ) + + lifecycle.attach() + lifecycle.attach() + assertEquals(1, registrations) + + lifecycle.detach() + lifecycle.detach() + assertEquals(1, unregistrations) + assertEquals(false, listenerActive) + + lifecycle.attach() + assertEquals(2, registrations) + } + + @Test + fun activityRecreation_removesListenersBeforeReattachingThem() { + val additions = mutableListOf() + val removals = mutableListOf() + val lifecycle = ActivityListenerLifecycle( + add = additions::add, + remove = removals::add, + ) + + lifecycle.attach("first activity") + lifecycle.detach() + lifecycle.attach("recreated activity") + lifecycle.detach() + lifecycle.detach() + + assertEquals(listOf("first activity", "recreated activity"), additions) + assertEquals(listOf("first activity", "recreated activity"), removals) + } + @Test fun unknownMethod_isReportedAsNotImplemented() { val plugin = BusymaxAndroidPlatformPlugin() diff --git a/test/app/first_day_of_week_test.dart b/test/app/first_day_of_week_test.dart index aa20ecd5..45be3c50 100644 --- a/test/app/first_day_of_week_test.dart +++ b/test/app/first_day_of_week_test.dart @@ -176,13 +176,13 @@ void main() { ); expect(find.text('effective: 3'), findsOneWidget); - expect(find.text('System default (Sunday)'), findsOneWidget); + expect(find.text('System Default (Sunday)'), findsOneWidget); systemWeekday.value = DateTime.friday; await tester.pump(); expect(find.text('effective: 3'), findsOneWidget); - expect(find.text('System default (Friday)'), findsOneWidget); + expect(find.text('System Default (Friday)'), findsOneWidget); }, ); diff --git a/test/features/settings/presentation/settings_screen_test.dart b/test/features/settings/presentation/settings_screen_test.dart index 5244a7d2..b2b6e55b 100644 --- a/test/features/settings/presentation/settings_screen_test.dart +++ b/test/features/settings/presentation/settings_screen_test.dart @@ -1246,7 +1246,7 @@ void main() { ); expect(row.values, BusyMaxFirstDayOfWeekPreference.values); expect(row.selected, BusyMaxFirstDayOfWeekPreference.system); - expect(row.labelFor(row.selected), startsWith('System default (')); + expect(row.labelFor(row.selected), startsWith('System Default (')); row.onSelected(BusyMaxFirstDayOfWeekPreference.saturday); await tester.pumpAndSettle(); expect( diff --git a/test/flutter_test_config.dart b/test/flutter_test_config.dart index 3d975786..9e1c22d8 100644 --- a/test/flutter_test_config.dart +++ b/test/flutter_test_config.dart @@ -11,6 +11,7 @@ Future testExecutable(FutureOr Function() testMain) async { TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; for (final channel in const [ MethodChannel('io.busystack.busymax/gtk_settings'), + MethodChannel('io.busystack.busymax/first_weekday'), MethodChannel('busymax/windows_weekday'), ]) { messenger.setMockMethodCallHandler(channel, (call) async { diff --git a/test/platform/android/android_date_picker_test.dart b/test/platform/android/android_date_picker_test.dart index c02b8699..a111f816 100644 --- a/test/platform/android/android_date_picker_test.dart +++ b/test/platform/android/android_date_picker_test.dart @@ -1,10 +1,134 @@ +import 'dart:async'; + +import 'package:busymax/l10n/generated/app_localizations.dart'; import 'package:busymax/src/android/presentation/android_date_picker.dart'; import 'package:busymax/src/l10n/week_preferences_scope.dart'; +import 'package:busymax/src/schedule/schedule_range.dart'; import 'package:flutter/material.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { + testWidgets( + 'system label, open picker, and real week range update from one controller', + (tester) async { + final source = _WeekdaySource(DateTime.monday); + final controller = BusyMaxSystemFirstWeekdayController(source); + final preference = ValueNotifier(BusyMaxFirstDayOfWeekPreference.system); + final language = ValueNotifier(const Locale('en')); + addTearDown(controller.dispose); + addTearDown(preference.dispose); + addTearDown(language.dispose); + + await tester.pumpWidget( + ValueListenableBuilder( + valueListenable: language, + builder: (context, locale, _) => MaterialApp( + locale: locale, + localizationsDelegates: const [ + AppLocalizations.delegate, + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + ], + supportedLocales: const [Locale('en'), Locale('de')], + builder: (context, child) => ValueListenableBuilder( + valueListenable: controller, + builder: (context, systemWeekday, _) => ValueListenableBuilder( + valueListenable: preference, + builder: (context, selected, _) => BusyMaxWeekPreferencesScope( + preference: selected, + systemWeekday: systemWeekday, + // A Sunday regional convention proves the native + // desktop value wins independently of UI language. + platformLocaleTag: 'en-US', + child: child!, + ), + ), + ), + home: Builder( + builder: (context) { + final firstWeekday = BusyMaxWeekPreferencesScope.firstWeekdayOf( + context, + ); + final range = ScheduleRange.week( + DateTime(2026, 9, 16), + firstWeekday: firstWeekday, + ); + return Column( + children: [ + Text( + busyMaxFirstDayOfWeekPreferenceLabel( + context, + BusyMaxFirstDayOfWeekPreference.system, + ), + ), + Text('range starts ${range.start.weekday}'), + TextButton( + onPressed: () => showBusyMaxDatePicker( + context: context, + initialDate: DateTime(2026, 9, 16), + firstDate: DateTime(2026), + lastDate: DateTime(2026, 12, 31), + ), + child: const Text('Open system picker'), + ), + ], + ); + }, + ), + ), + ), + ); + await tester.pump(); + expect(find.text('System Default (Monday)'), findsOneWidget); + expect(find.text('range starts 1'), findsOneWidget); + + await tester.tap(find.text('Open system picker')); + await tester.pumpAndSettle(); + BuildContext pickerContext = tester.element( + find.byType(DatePickerDialog), + ); + expect( + MaterialLocalizations.of(pickerContext).firstDayOfWeekIndex, + DateTime.monday, + ); + + source.change(DateTime.sunday); + await tester.pumpAndSettle(); + expect(find.text('System Default (Sunday)'), findsOneWidget); + pickerContext = tester.element(find.byType(DatePickerDialog)); + expect(MaterialLocalizations.of(pickerContext).firstDayOfWeekIndex, 0); + + preference.value = BusyMaxFirstDayOfWeekPreference.saturday; + source.change(DateTime.wednesday); + await tester.pumpAndSettle(); + expect(find.text('System Default (Wednesday)'), findsOneWidget); + pickerContext = tester.element(find.byType(DatePickerDialog)); + expect( + MaterialLocalizations.of(pickerContext).firstDayOfWeekIndex, + DateTime.saturday, + ); + + preference.value = BusyMaxFirstDayOfWeekPreference.system; + await tester.pumpAndSettle(); + pickerContext = tester.element(find.byType(DatePickerDialog)); + expect( + MaterialLocalizations.of(pickerContext).firstDayOfWeekIndex, + DateTime.wednesday, + ); + + language.value = const Locale('de'); + await tester.pumpAndSettle(); + expect(find.text('Systemstandard (Mittwoch)'), findsOneWidget); + pickerContext = tester.element(find.byType(DatePickerDialog)); + expect( + MaterialLocalizations.of(pickerContext).firstDayOfWeekIndex, + DateTime.wednesday, + ); + }, + ); + testWidgets( 'open picker follows preference changes without losing selection', (tester) async { @@ -107,3 +231,24 @@ void main() { expect(result, isNull); }); } + +final class _WeekdaySource implements BusyMaxSystemFirstWeekdaySource { + _WeekdaySource(this.value); + + int? value; + final _changes = StreamController.broadcast(sync: true); + + void change(int? weekday) { + value = weekday; + _changes.add(null); + } + + @override + Stream get changes => _changes.stream; + + @override + Future read() async => value; + + @override + void dispose() => unawaited(_changes.close()); +} diff --git a/test/platform/android_first_weekday_source_test.dart b/test/platform/android_first_weekday_source_test.dart new file mode 100644 index 00000000..7a469fda --- /dev/null +++ b/test/platform/android_first_weekday_source_test.dart @@ -0,0 +1,63 @@ +import 'package:busymax/src/l10n/week_preferences_scope.dart'; +import 'package:busymax/src/platform/android/android_first_weekday_source.dart'; +import 'package:busymax_android_platform/busymax_android_platform.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + test('Android setting event and resume each perform a fresh read', () async { + const methods = MethodChannel('busymax_test/android_weekday_methods'); + const events = EventChannel('busymax_test/android_weekday_events'); + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + MockStreamHandlerEventSink? sink; + var weekday = DateTime.monday; + var reads = 0; + messenger.setMockMethodCallHandler(methods, (call) async { + expect(call.method, 'getFirstWeekday'); + reads++; + return weekday; + }); + messenger.setMockStreamHandler( + events, + MockStreamHandler.inline( + onListen: (arguments, eventSink) { + sink = eventSink; + }, + ), + ); + addTearDown(() { + messenger + ..setMockMethodCallHandler(methods, null) + ..setMockStreamHandler(events, null); + }); + + final source = AndroidFirstWeekdaySource( + platform: BusyMaxAndroidPlatform(methodChannel: methods, events: events), + ); + final controller = BusyMaxSystemFirstWeekdayController(source); + await pumpEventQueue(); + expect(controller.value, DateTime.monday); + expect(reads, 1); + + weekday = DateTime.thursday; + sink!.success({'kind': 'systemSettingsChanged'}); + await pumpEventQueue(); + expect(reads, 2); + expect(controller.value, DateTime.thursday); + + weekday = DateTime.saturday; + controller.didChangeAppLifecycleState(AppLifecycleState.resumed); + await pumpEventQueue(); + expect(controller.value, DateTime.saturday); + expect(reads, 3); + + controller.dispose(); + sink!.success({'kind': 'systemSettingsChanged'}); + await pumpEventQueue(); + expect(reads, 3); + }); +} diff --git a/test/platform/linux_first_weekday_source_test.dart b/test/platform/linux_first_weekday_source_test.dart new file mode 100644 index 00000000..7b9bd505 --- /dev/null +++ b/test/platform/linux_first_weekday_source_test.dart @@ -0,0 +1,97 @@ +import 'package:busymax/src/l10n/week_preferences_scope.dart'; +import 'package:busymax/src/platform/linux_first_weekday_source.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + testWidgets('Linux notification causes a complete native reread', ( + tester, + ) async { + const methods = MethodChannel('busymax_test/linux_weekday_methods'); + const events = EventChannel('busymax_test/linux_weekday_events'); + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + MockStreamHandlerEventSink? sink; + var weekday = DateTime.monday; + var reads = 0; + var nativeCancels = 0; + var streamCancels = 0; + messenger.setMockMethodCallHandler(methods, (call) async { + switch (call.method) { + case 'getFirstWeekday': + reads++; + return weekday; + case 'cancelFirstWeekdayReads': + nativeCancels++; + return null; + default: + fail('Unexpected Linux weekday method: ${call.method}'); + } + }); + messenger.setMockStreamHandler( + events, + MockStreamHandler.inline( + onListen: (arguments, eventSink) { + sink = eventSink; + }, + onCancel: (arguments) { + streamCancels++; + }, + ), + ); + addTearDown(() { + messenger + ..setMockMethodCallHandler(methods, null) + ..setMockStreamHandler(events, null); + }); + + final controller = BusyMaxSystemFirstWeekdayController( + const LinuxFirstWeekdaySource(channel: methods, events: events), + ); + await tester.pump(); + expect(controller.value, DateTime.monday); + expect(reads, 1); + expect(sink, isNotNull); + + weekday = DateTime.sunday; + sink!.success(null); + await tester.pump(); + expect(controller.value, DateTime.sunday); + expect(reads, 2); + + controller.dispose(); + await tester.pump(); + expect(nativeCancels, 1); + expect(streamCancels, 1); + sink!.success(null); + await tester.pump(); + expect(reads, 2); + expect(tester.takeException(), isNull); + }); + + test('Linux native failures stay on the unavailable-data path', () async { + const missing = MethodChannel('busymax_test/linux_weekday_missing'); + const failed = MethodChannel('busymax_test/linux_weekday_failed'); + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + messenger.setMockMethodCallHandler(missing, (call) async { + throw MissingPluginException(); + }); + messenger.setMockMethodCallHandler(failed, (call) async { + throw PlatformException(code: 'unavailable'); + }); + addTearDown(() { + messenger + ..setMockMethodCallHandler(missing, null) + ..setMockMethodCallHandler(failed, null); + }); + + expect( + await const LinuxFirstWeekdaySource(channel: missing).read(), + isNull, + ); + expect(await const LinuxFirstWeekdaySource(channel: failed).read(), isNull); + }); +} diff --git a/test/platform/windows/windows_clock_preference_test.dart b/test/platform/windows/windows_clock_preference_test.dart index 0de43ca1..1399d0d8 100644 --- a/test/platform/windows/windows_clock_preference_test.dart +++ b/test/platform/windows/windows_clock_preference_test.dart @@ -79,4 +79,37 @@ void main() { expect(controller.value, DateTime.friday); }, ); + + test( + 'disposing Windows weekday source removes its channel handler', + () async { + const channel = MethodChannel('busymax_test/windows_weekday_dispose'); + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + final source = WindowsFirstWeekdaySource(channel: channel); + var changes = 0; + final subscription = source.changes.listen((_) => changes++); + + await messenger.handlePlatformMessage( + channel.name, + const StandardMethodCodec().encodeMethodCall( + MethodCall('weekdayChanged', DateTime.monday), + ), + (_) {}, + ); + expect(changes, 1); + + source.dispose(); + await messenger.handlePlatformMessage( + channel.name, + const StandardMethodCodec().encodeMethodCall( + MethodCall('weekdayChanged', DateTime.sunday), + ), + (_) {}, + ); + await Future.delayed(Duration.zero); + expect(changes, 1); + await subscription.cancel(); + }, + ); } From 7f10bf609bc8cdacbdeaeeaf908286c32c3cf7ca Mon Sep 17 00:00:00 2001 From: ChenZhenIO Date: Sat, 19 Sep 2026 09:53:17 -0700 Subject: [PATCH 22/25] Add tests for superseded weekday reads and delayed portal completions on Linux --- .../tests/first_weekday_preference_test.cc | 74 ++++++++++++++++++- 1 file changed, 72 insertions(+), 2 deletions(-) diff --git a/linux/runner/tests/first_weekday_preference_test.cc b/linux/runner/tests/first_weekday_preference_test.cc index 5d25c41f..8f2e4c4b 100644 --- a/linux/runner/tests/first_weekday_preference_test.cc +++ b/linux/runner/tests/first_weekday_preference_test.cc @@ -165,6 +165,16 @@ class PortalService { } } + bool CompleteNextDelayed(const char* value) { + if (delayed_.empty()) return false; + auto* invocation = delayed_.front(); + delayed_.erase(delayed_.begin()); + g_dbus_method_invocation_return_value( + invocation, + g_variant_new("(v)", g_variant_new_string(value))); + return true; + } + int reads() const { return reads_; } bool arguments_valid() const { return arguments_valid_; } @@ -241,6 +251,7 @@ void SetSetting(GSettings* settings, const char* value) { void TestPortalValues(PortalService& portal, GSettingsSchemaSource* schemas) { portal.SetAvailable(true); + g_autoptr(GSettings) settings = TestSettings(schemas); BusyMaxLinuxFirstWeekdayPreference reader(Configuration(portal, schemas)); const std::vector> weekdays = { {"monday", 1}, {"tuesday", 2}, {"wednesday", 3}, @@ -251,13 +262,71 @@ void TestPortalValues(PortalService& portal, portal.set_value(value); Check(Read(reader) == expected, "portal weekday maps to Dart numbering"); } + SetSetting(settings, "tuesday"); portal.set_value("default"); - Check(Read(reader) == 7, "portal default uses locale convention"); + Check(Read(reader) == 7, + "portal default bypasses stale direct GSettings and uses locale"); portal.set_value("not-a-weekday"); - Check(Read(reader) == 7, "malformed portal value uses locale convention"); + Check(Read(reader) == 7, + "malformed successful portal value bypasses stale direct GSettings " + "and uses locale"); Check(portal.arguments_valid(), "portal receives exact namespace and key"); } +void TestSupersededRead(PortalService& portal, + GSettingsSchemaSource* schemas) { + portal.SetAvailable(true); + portal.set_delay(true); + auto configuration = Configuration(portal, schemas); + configuration.portal_timeout_milliseconds = 1000; + BusyMaxLinuxFirstWeekdayPreference reader(std::move(configuration)); + + int first_callbacks = 0; + int second_callbacks = 0; + int third_callbacks = 0; + std::optional first_result; + std::optional second_result; + std::optional third_result; + const int reads_before_first = portal.reads(); + reader.Read([&](std::optional value) { + ++first_callbacks; + first_result = value; + }); + Check(SpinUntil([&] { return portal.reads() > reads_before_first; }), + "first superseded read reaches the delayed portal"); + + portal.set_delay(false); + portal.set_value("wednesday"); + reader.Read([&](std::optional value) { + ++second_callbacks; + second_result = value; + }); + Check(first_callbacks == 1 && !first_result.has_value(), + "superseded first read completes once without an authoritative value"); + Check(SpinUntil([&] { return second_callbacks == 1; }), + "newer read completes while the first portal request is unresolved"); + Check(second_result == 3, "newer read returns Wednesday"); + + Check(portal.CompleteNextDelayed("sunday"), + "stale first portal request remains available for late completion"); + portal.set_value("monday"); + reader.Read([&](std::optional value) { + ++third_callbacks; + third_result = value; + }); + Check(SpinUntil([&] { return third_callbacks == 1; }), + "reader remains usable after the stale portal request terminates"); + while (g_main_context_iteration(nullptr, FALSE)) { + } + + Check(first_callbacks == 1, + "late first portal completion does not invoke its callback twice"); + Check(second_callbacks == 1 && second_result == 3, + "late first portal completion does not replace the newer result"); + Check(third_callbacks == 1 && third_result == 1, + "fresh read after supersession returns Monday exactly once"); +} + void TestPackagedPortalWins(PortalService& portal, GSettingsSchemaSource* schemas) { portal.SetAvailable(true); @@ -408,6 +477,7 @@ int main(int argc, char** argv) { { PortalService portal(g_test_dbus_get_bus_address(bus)); TestPortalValues(portal, schemas); + TestSupersededRead(portal, schemas); TestPackagedPortalWins(portal, schemas); TestDirectAndFallbacks(portal, schemas); TestDirectNotifications(portal, schemas); From 3ab4ff6adfc97b033f3df7a21cc14f8e07d940b3 Mon Sep 17 00:00:00 2001 From: ChenZhenIO Date: Sat, 19 Sep 2026 10:00:57 -0700 Subject: [PATCH 23/25] Fix Android SDK manager path in CI --- .github/workflows/flutter-android.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/flutter-android.yml b/.github/workflows/flutter-android.yml index 0c083a3e..0d58c6ce 100644 --- a/.github/workflows/flutter-android.yml +++ b/.github/workflows/flutter-android.yml @@ -34,7 +34,7 @@ jobs: - name: Install Android API and Build Tools 37 run: >- - sdkmanager + "$ANDROID_SDK_ROOT/cmdline-tools/latest/bin/sdkmanager" 'platforms;android-37.0' 'build-tools;37.0.0' From 7e0997bdb5de87c1773ec19d2892712dede462f9 Mon Sep 17 00:00:00 2001 From: ChenZhenIO Date: Sat, 19 Sep 2026 10:10:43 -0700 Subject: [PATCH 24/25] Fix quiet-hours test timezone --- .../android_notification_reconciliation_test.dart | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/test/platform/android/android_notification_reconciliation_test.dart b/test/platform/android/android_notification_reconciliation_test.dart index 0ef4709a..bc672ce1 100644 --- a/test/platform/android/android_notification_reconciliation_test.dart +++ b/test/platform/android/android_notification_reconciliation_test.dart @@ -217,8 +217,16 @@ void main() { }); test('quiet-hours deferral survives a second reconciliation', () async { - final tomorrow = DateTime.now().add(const Duration(days: 1)); - final requested = DateTime(tomorrow.year, tomorrow.month, tomorrow.day, 23); + final notifications = service(); + await notifications.initialize(timeZoneId: 'America/Vancouver'); + final today = tz.TZDateTime.now(tz.local); + final requested = tz.TZDateTime( + tz.local, + today.year, + today.month, + today.day + 1, + 23, + ); settings = settings.copyWith( quietHoursEnabled: true, quietHoursStart: '22:00', @@ -229,8 +237,6 @@ void main() { generation: 'generation-1', scheduledAt: requested, ); - final notifications = service(); - await notifications.initialize(timeZoneId: 'America/Vancouver'); await notifications.reconcile(); final mapping = await database From 153bd18b07fb8586558648893486286caa6dbe72 Mon Sep 17 00:00:00 2001 From: ChenZhenIO Date: Sat, 19 Sep 2026 10:28:50 -0700 Subject: [PATCH 25/25] Fix Windows CI path assumptions --- test/platform/linux_autostart_service_test.dart | 4 ++-- tool/check_platform_boundaries.dart | 12 ++++++++---- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/test/platform/linux_autostart_service_test.dart b/test/platform/linux_autostart_service_test.dart index 02e2cbe2..a153fb6d 100644 --- a/test/platform/linux_autostart_service_test.dart +++ b/test/platform/linux_autostart_service_test.dart @@ -209,7 +209,7 @@ void main() { } }, ); - }); + }, skip: !Platform.isLinux); for (final entryCase in <({String contents, bool enabled})>[ (contents: 'Hidden=true', enabled: false), @@ -365,7 +365,7 @@ void main() { expect(await service.isEnabled(), isFalse); expect(await file.exists(), isFalse); - }); + }, skip: !Platform.isLinux); test('writes the Snap command in the Snap user configuration', () async { final configHome = await Directory.systemTemp.createTemp( diff --git a/tool/check_platform_boundaries.dart b/tool/check_platform_boundaries.dart index f77b3310..cc998ffa 100644 --- a/tool/check_platform_boundaries.dart +++ b/tool/check_platform_boundaries.dart @@ -229,8 +229,12 @@ bool _isLegacyLinuxPresentation(String relative) { } String _relative(String root, String path) { - final prefix = root.endsWith(Platform.pathSeparator) - ? root - : '$root${Platform.pathSeparator}'; - return path.startsWith(prefix) ? path.substring(prefix.length) : path; + final normalizedRoot = root + .replaceAll('\\', '/') + .replaceAll(RegExp(r'/+$'), ''); + final normalizedPath = path.replaceAll('\\', '/'); + final prefix = '$normalizedRoot/'; + return normalizedPath.startsWith(prefix) + ? normalizedPath.substring(prefix.length) + : normalizedPath; }