diff --git a/.github/workflows/flutter-android.yml b/.github/workflows/flutter-android.yml new file mode 100644 index 00000000..0d58c6ce --- /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.4' + cache: true + + - name: Install Android API and Build Tools 37 + run: >- + "$ANDROID_SDK_ROOT/cmdline-tools/latest/bin/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/.github/workflows/flutter-linux.yml b/.github/workflows/flutter-linux.yml index 73740ede..b59d8bd6 100644 --- a/.github/workflows/flutter-linux.yml +++ b/.github/workflows/flutter-linux.yml @@ -32,13 +32,15 @@ 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 with: channel: stable - flutter-version: '3.47.2' + flutter-version: '3.47.4' cache: true - name: Verify pinned Flutter and bundled Dart @@ -50,8 +52,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" @@ -87,13 +89,36 @@ 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 + 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 \ + -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/.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/.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..a70f60e4 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.4, bundled Dart +3.13.3, 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 00000000..79a4b405 Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ 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 00000000..597c3146 Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png new file mode 100644 index 00000000..79a4b405 Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png differ 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 00000000..be74f97b Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png new file mode 100644 index 00000000..c9045d65 Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png differ 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 00000000..be74f97b Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 00000000..4a2e8bd7 Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ 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 00000000..7525f4a1 Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png new file mode 100644 index 00000000..4a2e8bd7 Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ 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 00000000..32218881 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png new file mode 100644 index 00000000..a1539c5f Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png new file mode 100644 index 00000000..32218881 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 00000000..1a9822d7 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png new file mode 100644 index 00000000..5b7d5ce3 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png differ 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 00000000..1a9822d7 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/android/app/src/main/res/values-night/styles.xml b/android/app/src/main/res/values-night/styles.xml new file mode 100644 index 00000000..06952be7 --- /dev/null +++ b/android/app/src/main/res/values-night/styles.xml @@ -0,0 +1,18 @@ + + + + + + + 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..fbfdfa1b --- /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.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 +android.builtInKotlin=true diff --git a/android/gradle/wrapper/gradle-wrapper.jar b/android/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 00000000..13372aef Binary files /dev/null and b/android/gradle/wrapper/gradle-wrapper.jar differ 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..1086b33e --- /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.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 +} + +include(":app") diff --git a/docs/android_implementation_status.md b/docs/android_implementation_status.md new file mode 100644 index 00000000..c6b3de8d --- /dev/null +++ b/docs/android_implementation_status.md @@ -0,0 +1,57 @@ +# 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 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 +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 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. +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. 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 +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..30853f33 --- /dev/null +++ b/docs/android_provider_parity.md @@ -0,0 +1,39 @@ +# 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, 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 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. | +| 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 +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_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..37b12bd1 --- /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.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.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 +`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..19edfb4c --- /dev/null +++ b/docs/android_verification.md @@ -0,0 +1,174 @@ +# 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. + +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 | +|---|---| +| 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.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 | +| 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 | +| 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,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` | 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. | +| 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 | `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. | + +Android composition has an explicit `lib/main_android.dart`; the Linux and +Windows entrypoints remain separate. + +## Current test-signed release artifact record + +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` | 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 +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, 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. | +| 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 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, 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. | +| 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. | + +## 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 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. | +| 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/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/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/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/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/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/l10n/app_ar.arb b/lib/l10n/app_ar.arb index cc071677..7bba1cef 100644 --- a/lib/l10n/app_ar.arb +++ b/lib/l10n/app_ar.arb @@ -1,5 +1,9 @@ { + "launchAtLoginManagedExternally": "تم تشغيل BusyMax من إعدادات بدء التشغيل في سطح المكتب. أزل إدخاله هناك، ثم أنهِ BusyMax وافتحه مجددًا لاستخدام هذا المفتاح.", "timeFormat": "تنسيق الوقت", + "firstDayOfWeek": "أول أيام الأسبوع", + "systemDefault": "الإعداد الافتراضي للنظام", + "systemDefaultResolved": "الإعداد الافتراضي للنظام (⁨{weekday}⁩)", "timeFormatTwelveHour": "12 ساعة", "timeFormatTwentyFourHour": "24 ساعة", "timeEndOfDay": "نهاية اليوم", @@ -1024,5 +1028,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 dcc96092..f50e4bd9 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -1,5 +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", @@ -1087,5 +1091,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 74d16664..a44eabbf 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1,5 +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", @@ -1563,5 +1568,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 faaec07f..e70e9026 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -1,5 +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", @@ -1087,5 +1091,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 5e37c343..c454497b 100644 --- a/lib/l10n/app_et.arb +++ b/lib/l10n/app_et.arb @@ -1,5 +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", @@ -1202,5 +1206,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 c35fee64..2d2b1e71 100644 --- a/lib/l10n/app_fa.arb +++ b/lib/l10n/app_fa.arb @@ -1,5 +1,9 @@ { + "launchAtLoginManagedExternally": "BusyMax از تنظیمات راه‌اندازی خودکار میزکار شما اجرا شده است. ورودی آن را در آنجا حذف کنید، سپس از BusyMax خارج شوید و دوباره آن را باز کنید تا بتوانید از این کلید استفاده کنید.", "timeFormat": "قالب زمان", + "firstDayOfWeek": "روز اول هفته", + "systemDefault": "پیش‌فرض سیستم", + "systemDefaultResolved": "پیش‌فرض سیستم (⁨{weekday}⁩)", "timeFormatTwelveHour": "۱۲ ساعته", "timeFormatTwentyFourHour": "۲۴ ساعته", "timeEndOfDay": "پایان روز", @@ -1098,5 +1102,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 a3cb1bdc..ce86ccdd 100644 --- a/lib/l10n/app_fi.arb +++ b/lib/l10n/app_fi.arb @@ -1,5 +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", @@ -1024,5 +1028,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 5046e9b5..ba1f2146 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -1,5 +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", @@ -1087,5 +1091,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 37ebe383..a6ae8cd3 100644 --- a/lib/l10n/app_hi.arb +++ b/lib/l10n/app_hi.arb @@ -1,5 +1,9 @@ { + "launchAtLoginManagedExternally": "BusyMax आपके डेस्कटॉप की स्टार्टअप सेटिंग से शुरू हुआ था। वहाँ से इसकी प्रविष्टि हटाएँ, फिर इस स्विच का उपयोग करने के लिए BusyMax से बाहर निकलें और इसे दोबारा खोलें।", "timeFormat": "समय प्रारूप", + "firstDayOfWeek": "सप्ताह का पहला दिन", + "systemDefault": "सिस्टम डिफ़ॉल्ट", + "systemDefaultResolved": "सिस्टम डिफ़ॉल्ट ({weekday})", "timeFormatTwelveHour": "12 घंटे", "timeFormatTwentyFourHour": "24 घंटे", "timeEndOfDay": "दिन का अंत", @@ -1024,5 +1028,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 a3024906..2c9b75cb 100644 --- a/lib/l10n/app_it.arb +++ b/lib/l10n/app_it.arb @@ -1,5 +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", @@ -1024,5 +1028,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 1105f9cb..2cdeaca7 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -1,5 +1,9 @@ { + "launchAtLoginManagedExternally": "BusyMax はデスクトップの自動起動設定によって起動されました。このスイッチを使うには、そこで BusyMax の項目を削除してから、BusyMax を終了して開き直してください。", "timeFormat": "時刻の表示形式", + "firstDayOfWeek": "週の最初の曜日", + "systemDefault": "システムの既定値", + "systemDefaultResolved": "システムの既定値({weekday})", "timeFormatTwelveHour": "12時間表示", "timeFormatTwentyFourHour": "24時間表示", "timeEndOfDay": "一日の終わり", @@ -1024,5 +1028,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 6d867555..f25807ee 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -1,5 +1,9 @@ { + "launchAtLoginManagedExternally": "데스크톱의 시작 프로그램 설정에 의해 BusyMax가 실행되었습니다. 이 스위치를 사용하려면 해당 설정에서 BusyMax 항목을 제거한 다음 BusyMax를 완전히 종료하고 다시 여세요.", "timeFormat": "시간 형식", + "firstDayOfWeek": "한 주의 시작 요일", + "systemDefault": "시스템 기본값", + "systemDefaultResolved": "시스템 기본값({weekday})", "timeFormatTwelveHour": "12시간", "timeFormatTwentyFourHour": "24시간", "timeEndOfDay": "하루의 끝", @@ -1024,5 +1028,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 262365d6..edc8996f 100644 --- a/lib/l10n/app_pl.arb +++ b/lib/l10n/app_pl.arb @@ -1,5 +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", @@ -1734,5 +1738,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 46eed407..ad3b62b7 100644 --- a/lib/l10n/app_pt_PT.arb +++ b/lib/l10n/app_pt_PT.arb @@ -1,5 +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", @@ -1024,5 +1028,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 a4f1a4e2..b4f4cfa5 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -1,5 +1,9 @@ { + "launchAtLoginManagedExternally": "BusyMax запущен через настройки автозапуска рабочего стола. Удалите его запись там, затем полностью завершите и снова откройте BusyMax, чтобы использовать этот переключатель.", "timeFormat": "Формат времени", + "firstDayOfWeek": "Первый день недели", + "systemDefault": "Системное значение", + "systemDefaultResolved": "Системное значение ({weekday})", "timeFormatTwelveHour": "12 часов", "timeFormatTwentyFourHour": "24 часа", "timeEndOfDay": "конец дня", @@ -1024,5 +1028,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 108340a1..2cba9cd8 100644 --- a/lib/l10n/app_vi.arb +++ b/lib/l10n/app_vi.arb @@ -1,5 +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", @@ -1024,5 +1028,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 ea8614c3..f87c01fe 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -1,5 +1,9 @@ { + "launchAtLoginManagedExternally": "BusyMax 由桌面的启动设置启动。请在那里移除 BusyMax 的条目,然后完全退出并重新打开 BusyMax,以使用此开关。", "timeFormat": "时间格式", + "firstDayOfWeek": "一周的第一天", + "systemDefault": "系统默认", + "systemDefaultResolved": "系统默认({weekday})", "timeFormatTwelveHour": "12小时制", "timeFormatTwentyFourHour": "24小时制", "timeEndOfDay": "当天结束", @@ -1024,5 +1028,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 7adbcb39..124fcb11 100644 --- a/lib/l10n/app_zh_Hans.arb +++ b/lib/l10n/app_zh_Hans.arb @@ -1,5 +1,9 @@ { + "launchAtLoginManagedExternally": "BusyMax 由桌面的启动设置启动。请在那里移除 BusyMax 的条目,然后完全退出并重新打开 BusyMax,以使用此开关。", "timeFormat": "时间格式", + "firstDayOfWeek": "一周的第一天", + "systemDefault": "系统默认", + "systemDefaultResolved": "系统默认({weekday})", "timeFormatTwelveHour": "12小时制", "timeFormatTwentyFourHour": "24小时制", "timeEndOfDay": "当天结束", @@ -1024,5 +1028,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 1b16e4fd..d548d43a 100644 --- a/lib/l10n/app_zh_Hant.arb +++ b/lib/l10n/app_zh_Hant.arb @@ -1,5 +1,9 @@ { + "launchAtLoginManagedExternally": "BusyMax 由桌面的啟動設定啟動。請在那裡移除 BusyMax 的項目,然後完全結束並重新開啟 BusyMax,以使用此開關。", "timeFormat": "時間格式", + "firstDayOfWeek": "一週的第一天", + "systemDefault": "系統預設", + "systemDefaultResolved": "系統預設({weekday})", "timeFormatTwelveHour": "12小時制", "timeFormatTwentyFourHour": "24小時制", "timeEndOfDay": "當天結束", @@ -1024,5 +1028,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 085a3033..f98fc773 100644 --- a/lib/l10n/generated/app_localizations.dart +++ b/lib/l10n/generated/app_localizations.dart @@ -135,12 +135,36 @@ 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: /// **'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: @@ -4861,6 +4885,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 1a12e206..f93994a1 100644 --- a/lib/l10n/generated/app_localizations_ar.dart +++ b/lib/l10n/generated/app_localizations_ar.dart @@ -12,9 +12,24 @@ import 'app_localizations.dart'; class AppLocalizationsAr extends AppLocalizations { AppLocalizationsAr([String locale = 'ar']) : super(locale); + @override + String get launchAtLoginManagedExternally => + 'تم تشغيل BusyMax من إعدادات بدء التشغيل في سطح المكتب. أزل إدخاله هناك، ثم أنهِ BusyMax وافتحه مجددًا لاستخدام هذا المفتاح.'; + @override String get timeFormat => 'تنسيق الوقت'; + @override + String get firstDayOfWeek => 'أول أيام الأسبوع'; + + @override + String get systemDefault => 'الإعداد الافتراضي للنظام'; + + @override + String systemDefaultResolved(String weekday) { + return 'الإعداد الافتراضي للنظام (⁨$weekday⁩)'; + } + @override String get timeFormatTwelveHour => '12 ساعة'; @@ -2985,4 +3000,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 343efae8..f67bb9a1 100644 --- a/lib/l10n/generated/app_localizations_de.dart +++ b/lib/l10n/generated/app_localizations_de.dart @@ -12,9 +12,24 @@ 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'; + @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'; @@ -2921,4 +2936,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 aa8cc416..429ac2b8 100644 --- a/lib/l10n/generated/app_localizations_en.dart +++ b/lib/l10n/generated/app_localizations_en.dart @@ -12,9 +12,24 @@ 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'; + @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'; @@ -2953,4 +2968,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 d46e319d..bcfc3595 100644 --- a/lib/l10n/generated/app_localizations_es.dart +++ b/lib/l10n/generated/app_localizations_es.dart @@ -12,9 +12,24 @@ 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'; + @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'; @@ -2925,4 +2940,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 055d4941..59d2fe70 100644 --- a/lib/l10n/generated/app_localizations_et.dart +++ b/lib/l10n/generated/app_localizations_et.dart @@ -12,9 +12,24 @@ 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'; + @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'; @@ -2900,4 +2915,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 eaa75e4d..d12a8570 100644 --- a/lib/l10n/generated/app_localizations_fa.dart +++ b/lib/l10n/generated/app_localizations_fa.dart @@ -12,9 +12,24 @@ import 'app_localizations.dart'; class AppLocalizationsFa extends AppLocalizations { AppLocalizationsFa([String locale = 'fa']) : super(locale); + @override + String get launchAtLoginManagedExternally => + 'BusyMax از تنظیمات راه‌اندازی خودکار میزکار شما اجرا شده است. ورودی آن را در آنجا حذف کنید، سپس از BusyMax خارج شوید و دوباره آن را باز کنید تا بتوانید از این کلید استفاده کنید.'; + @override String get timeFormat => 'قالب زمان'; + @override + String get firstDayOfWeek => 'روز اول هفته'; + + @override + String get systemDefault => 'پیش‌فرض سیستم'; + + @override + String systemDefaultResolved(String weekday) { + return 'پیش‌فرض سیستم (⁨$weekday⁩)'; + } + @override String get timeFormatTwelveHour => '۱۲ ساعته'; @@ -2956,4 +2971,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 b86302cd..77de90c6 100644 --- a/lib/l10n/generated/app_localizations_fi.dart +++ b/lib/l10n/generated/app_localizations_fi.dart @@ -12,9 +12,24 @@ 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'; + @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'; @@ -2950,4 +2965,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 624906d5..b3ea7fc3 100644 --- a/lib/l10n/generated/app_localizations_fr.dart +++ b/lib/l10n/generated/app_localizations_fr.dart @@ -12,9 +12,24 @@ 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'; + @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'; @@ -2926,4 +2941,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 90187393..194ad5ab 100644 --- a/lib/l10n/generated/app_localizations_hi.dart +++ b/lib/l10n/generated/app_localizations_hi.dart @@ -12,9 +12,24 @@ import 'app_localizations.dart'; class AppLocalizationsHi extends AppLocalizations { AppLocalizationsHi([String locale = 'hi']) : super(locale); + @override + String get launchAtLoginManagedExternally => + 'BusyMax आपके डेस्कटॉप की स्टार्टअप सेटिंग से शुरू हुआ था। वहाँ से इसकी प्रविष्टि हटाएँ, फिर इस स्विच का उपयोग करने के लिए BusyMax से बाहर निकलें और इसे दोबारा खोलें।'; + @override String get timeFormat => 'समय प्रारूप'; + @override + String get firstDayOfWeek => 'सप्ताह का पहला दिन'; + + @override + String get systemDefault => 'सिस्टम डिफ़ॉल्ट'; + + @override + String systemDefaultResolved(String weekday) { + return 'सिस्टम डिफ़ॉल्ट ($weekday)'; + } + @override String get timeFormatTwelveHour => '12 घंटे'; @@ -2921,4 +2936,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 a118f630..d7d4996b 100644 --- a/lib/l10n/generated/app_localizations_it.dart +++ b/lib/l10n/generated/app_localizations_it.dart @@ -12,9 +12,24 @@ 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'; + @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'; @@ -2942,4 +2957,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 1a3121b2..89af0ce3 100644 --- a/lib/l10n/generated/app_localizations_ja.dart +++ b/lib/l10n/generated/app_localizations_ja.dart @@ -12,9 +12,24 @@ 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 => '時刻の表示形式'; + @override + String get firstDayOfWeek => '週の最初の曜日'; + + @override + String get systemDefault => 'システムの既定値'; + + @override + String systemDefaultResolved(String weekday) { + return 'システムの既定値($weekday)'; + } + @override String get timeFormatTwelveHour => '12時間表示'; @@ -2858,4 +2873,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 42871a56..93698eed 100644 --- a/lib/l10n/generated/app_localizations_ko.dart +++ b/lib/l10n/generated/app_localizations_ko.dart @@ -12,9 +12,24 @@ 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 => '시간 형식'; + @override + String get firstDayOfWeek => '한 주의 시작 요일'; + + @override + String get systemDefault => '시스템 기본값'; + + @override + String systemDefaultResolved(String weekday) { + return '시스템 기본값($weekday)'; + } + @override String get timeFormatTwelveHour => '12시간'; @@ -2862,4 +2877,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 a4b4f087..6aad0970 100644 --- a/lib/l10n/generated/app_localizations_pl.dart +++ b/lib/l10n/generated/app_localizations_pl.dart @@ -12,9 +12,24 @@ 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'; + @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'; @@ -3095,4 +3110,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 1d3f5cab..437533b5 100644 --- a/lib/l10n/generated/app_localizations_pt.dart +++ b/lib/l10n/generated/app_localizations_pt.dart @@ -12,9 +12,24 @@ 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'; + @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'; @@ -2953,15 +2968,78 @@ 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`). 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'; + @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'; @@ -5888,4 +5966,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 8107a4ce..11ad24bc 100644 --- a/lib/l10n/generated/app_localizations_ru.dart +++ b/lib/l10n/generated/app_localizations_ru.dart @@ -12,9 +12,24 @@ import 'app_localizations.dart'; class AppLocalizationsRu extends AppLocalizations { AppLocalizationsRu([String locale = 'ru']) : super(locale); + @override + String get launchAtLoginManagedExternally => + 'BusyMax запущен через настройки автозапуска рабочего стола. Удалите его запись там, затем полностью завершите и снова откройте BusyMax, чтобы использовать этот переключатель.'; + @override String get timeFormat => 'Формат времени'; + @override + String get firstDayOfWeek => 'Первый день недели'; + + @override + String get systemDefault => 'Системное значение'; + + @override + String systemDefaultResolved(String weekday) { + return 'Системное значение ($weekday)'; + } + @override String get timeFormatTwelveHour => '12 часов'; @@ -2945,4 +2960,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 10ca3c5e..2d8f1e7c 100644 --- a/lib/l10n/generated/app_localizations_vi.dart +++ b/lib/l10n/generated/app_localizations_vi.dart @@ -12,9 +12,24 @@ 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ờ'; + @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ờ'; @@ -2918,4 +2933,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 fd5047c2..f2f6e30f 100644 --- a/lib/l10n/generated/app_localizations_zh.dart +++ b/lib/l10n/generated/app_localizations_zh.dart @@ -12,9 +12,24 @@ 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 => '时间格式'; + @override + String get firstDayOfWeek => '一周的第一天'; + + @override + String get systemDefault => '系统默认'; + + @override + String systemDefaultResolved(String weekday) { + return '系统默认($weekday)'; + } + @override String get timeFormatTwelveHour => '12小时制'; @@ -2821,15 +2836,78 @@ 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`). class AppLocalizationsZhHans extends AppLocalizationsZh { AppLocalizationsZhHans() : super('zh_Hans'); + @override + String get launchAtLoginManagedExternally => + 'BusyMax 由桌面的启动设置启动。请在那里移除 BusyMax 的条目,然后完全退出并重新打开 BusyMax,以使用此开关。'; + @override String get timeFormat => '时间格式'; + @override + String get firstDayOfWeek => '一周的第一天'; + + @override + String get systemDefault => '系统默认'; + + @override + String systemDefaultResolved(String weekday) { + return '系统默认($weekday)'; + } + @override String get timeFormatTwelveHour => '12小时制'; @@ -5636,15 +5714,78 @@ 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`). class AppLocalizationsZhHant extends AppLocalizationsZh { AppLocalizationsZhHant() : super('zh_Hant'); + @override + String get launchAtLoginManagedExternally => + 'BusyMax 由桌面的啟動設定啟動。請在那裡移除 BusyMax 的項目,然後完全結束並重新開啟 BusyMax,以使用此開關。'; + @override String get timeFormat => '時間格式'; + @override + String get firstDayOfWeek => '一週的第一天'; + + @override + String get systemDefault => '系統預設'; + + @override + String systemDefaultResolved(String weekday) { + return '系統預設($weekday)'; + } + @override String get timeFormatTwelveHour => '12小時制'; @@ -8452,4 +8593,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/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..a858a28e --- /dev/null +++ b/lib/src/android/android_app.dart @@ -0,0 +1,332 @@ +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 '../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'; +import 'presentation/android_tasks_screen.dart'; + +final androidSelectedDestinationProvider = StateProvider((ref) => 0); + +class AndroidBusyMaxApp extends ConsumerStatefulWidget { + const AndroidBusyMaxApp({super.key}); + + @override + 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', + 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: BusyMaxWeekPreferencesScope( + preference: settings.firstDayOfWeekPreference, + systemWeekday: _firstWeekdayController.value, + platformLocaleTag: WidgetsBinding.instance.platformDispatcher.locale + .toLanguageTag(), + child: BusyMaxWeekPreferencesStartupGate( + preference: settings.firstDayOfWeekPreference, + systemValueInitialized: _firstWeekdayController.isInitialized, + 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..b8052756 --- /dev/null +++ b/lib/src/android/android_authorization.dart @@ -0,0 +1,348 @@ +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); + 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); + 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 { + 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 + 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 { + 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 + 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.startsWith('google:')) { + await _platform.removeAuthorization( + provider: BusyProvider.google.storageValue, + accountId: 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) => OAuthTokenSet( + accessToken: token.accessToken, + expiresAtUtc: + token.expiresAtUtc ?? + DateTime.now().toUtc().add(const Duration(minutes: 45)), + tokenType: 'Bearer', + // 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.', + ); +} + +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..680d7a8e --- /dev/null +++ b/lib/src/android/android_background.dart @@ -0,0 +1,214 @@ +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'; +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 && + 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 { + 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', + ); + 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( + 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..1ea0122c --- /dev/null +++ b/lib/src/android/android_notifications.dart @@ -0,0 +1,1018 @@ +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'; +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.', + ), +); + +/// 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, +) 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, + AndroidNotificationBackend? backend, + Future Function()? exactAlarmCapability, + }) : _database = database, + _settings = settings, + _platform = platform ?? BusyMaxAndroidPlatform.instance, + _strings = strings ?? _defaultStrings, + _notifications = backend ?? FlutterAndroidNotificationBackend(plugin), + _exactAlarmCapability = exactAlarmCapability; + + final AppDatabase _database; + final AppSettings Function() _settings; + final BusyMaxAndroidPlatform _platform; + final AndroidNotificationStrings Function() _strings; + final AndroidNotificationBackend _notifications; + final Future Function()? _exactAlarmCapability; + 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 _notifications.initialize( + settings: const InitializationSettings( + android: AndroidInitializationSettings('ic_notification'), + ), + onDidReceiveNotificationResponse: _foregroundResponse, + onDidReceiveBackgroundNotificationResponse: + busyMaxNotificationBackgroundResponse, + ); + final launch = await _notifications.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 { + return _notifications.requestNotificationPermission(); + } + + Future requestExactAlarmPermission() async { + return _notifications.requestExactAlarmPermission(); + } + + Future canScheduleExactly() async { + final override = _exactAlarmCapability; + if (override != null) return override(); + return _notifications.canScheduleExactly(); + } + + @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 _notifications.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 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 _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 rows = + await (_database.select(_database.notificationSchedule)..where( + (table) => + table.accountId.isIn(eligibleAccounts) & + table.sentAtUtc.isNull() & + table.dismissedAtUtc.isNull(), + )) + .get(); + rows.removeWhere((row) { + if ((row.sourceType == 'event' && !settings.notifyEventReminders) || + (row.sourceType == 'task' && !settings.notifyTaskReminders) || + !const {'event', 'task'}.contains(row.sourceType)) { + return true; + } + final effectiveAt = applyAndroidQuietHours(_effectiveAt(row), settings); + final mapping = mappingByScheduleId[row.id]; + final mappingMatchesGeneration = mapping?.generation == row.generation; + final remainsPending = + 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( + (a, b) => applyAndroidQuietHours( + _effectiveAt(a), + settings, + ).compareTo(applyAndroidQuietHours(_effectiveAt(b), settings)), + ); + final desired = rows.take(_maximumScheduledAlarms).toList(); + final desiredIds = {for (final row in desired) row.id}; + + for (final mapping in mappings) { + if (!desiredIds.contains(mapping.scheduleId)) { + await _notifications.cancel(id: mapping.platformId); + await (_database.delete( + _database.androidNotificationMappings, + )..where((table) => table.scheduleId.equals(mapping.scheduleId))).go(); + } + } + + _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, + }; + 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 = + sameGeneration && + (activeIds.contains(mapping!.platformId) || + (!activeStateKnown && + !pendingIds.contains(mapping.platformId) && + effectiveAt <= nowMillis)); + final unchangedPending = + mapping != null && + sameGeneration && + mapping.scheduledAtUtc == effectiveAt && + mapping.state == registrationState && + pendingIds.contains(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); + final payload = _AndroidReminderPayload( + scheduleId: row.id, + generation: row.generation, + sourceType: row.sourceType, + accountId: row.accountId, + sourceId: row.sourceId, + ).encode(); + final scheduledAt = effectiveAt <= nowMillis + ? now.add(const Duration(seconds: 5)).millisecondsSinceEpoch + : effectiveAt; + await _notifications.zonedSchedule( + id: platformId, + title: title, + body: body, + scheduledDate: tz.TZDateTime.fromMillisecondsSinceEpoch( + tz.local, + scheduledAt, + ), + 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: androidReminderNotificationActions(strings), + ), + ), + 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, + state: Value(registrationState), + updatedAtUtc: now.millisecondsSinceEpoch, + ), + ); + } + await _reconcileDailySummary( + settings: settings, + eligibleAccounts: eligibleAccounts, + exact: exact, + pendingIds: pendingIds, + activeIds: activeIds, + ); + } + + Future _reconcileDailySummary({ + required AppSettings settings, + required Set eligibleAccounts, + required bool exact, + required Set pendingIds, + required Set activeIds, + }) async { + const platformId = 0x425903; + final existing = await _database + .select(_database.androidDailySummarySchedules) + .get(); + if (!settings.notifyDueToday || eligibleAccounts.isEmpty) { + await _notifications.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 _notifications.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 _notifications.cancel(id: platformId); + await (_database.delete( + _database.androidDailySummarySchedules, + )..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; + 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)) { + scheduled = now.add(const Duration(seconds: 10)); + } + final scheduledAt = applyAndroidQuietHours( + scheduled.millisecondsSinceEpoch, + settings, + ); + final unchanged = + current != null && + current.taskCount == count && + current.scheduledAtUtc == scheduledAt && + current.state == registrationState && + pendingIds.contains(platformId); + if (unchanged) return; + if (current != null) await _notifications.cancel(id: platformId); + final generation = '$localDate:$count:$scheduledAt'; + await _notifications.zonedSchedule( + id: platformId, + title: title, + body: body, + 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: [androidOpenNotificationAction(strings)], + ), + ), + 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, + state: Value(registrationState), + 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; +} + +/// 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, + 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, +) => 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, + bool remainsDisplayed = false, +}) => + effectiveAt <= horizon && + (effectiveAt > now || remainsPending || remainsDisplayed); + +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_availability_dialog.dart b/lib/src/android/presentation/android_availability_dialog.dart new file mode 100644 index 00000000..785da5b7 --- /dev/null +++ b/lib/src/android/presentation/android_availability_dialog.dart @@ -0,0 +1,232 @@ +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 '../../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'; +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'); + } + 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'); + } + } 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.succeeded && + entry.value.busySlots.isEmpty, + unknown: !entry.value.succeeded, + intervals: [ + for (final value in entry.value.busySlots) + 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_date_picker.dart b/lib/src/android/presentation/android_date_picker.dart new file mode 100644 index 00000000..5d0b41bf --- /dev/null +++ b/lib/src/android/presentation/android_date_picker.dart @@ -0,0 +1,458 @@ +// 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_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 new file mode 100644 index 00000000..8cba5a2a --- /dev/null +++ b/lib/src/android/presentation/android_schedule_screen.dart @@ -0,0 +1,2805 @@ +import 'dart:async'; +import 'dart:convert'; + +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'; + +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'; +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'; +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'; +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_date_picker.dart'; +import 'android_settings_screen.dart'; +import 'android_tasks_screen.dart'; + +final _androidScheduleItemsProvider = FutureProvider.autoDispose + .family< + List, + ({ + DateTime anchor, + ScheduleViewMode mode, + String query, + int firstWeekday, + ScheduleSearchCriteria? search, + }) + >((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: + 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, + ), + ); + }); + +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 = ''; + 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() { + _searchController.dispose(); + super.dispose(); + } + + @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, + search: _searching ? _searchCriteria : null, + firstWeekday: _firstWeekday(context), + )), + ); + return Scaffold( + 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), + ) + : 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: _searching ? _closeSearch : _openSearch, + icon: Icon(_searching ? Icons.close : Icons.search), + ), + IconButton( + tooltip: _searching + ? context.l10n.searchFiltersAction + : context.l10n.collectionSettings, + onPressed: () => + _searching ? _showSearchFilters() : _showSources(context), + icon: const Icon(Icons.tune), + ), + 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()), + error: (error, _) => _Message( + icon: Icons.error_outline, + title: context.l10n.scheduleUnavailable, + detail: '$error', + ), + data: (value) => + value.isEmpty && (_searching || mode == ScheduleViewMode.agenda) + ? _Message( + icon: !_searching ? Icons.event_busy : Icons.search_off, + title: !_searching + ? context.l10n.noEventsOrTasks + : _searchCriteria?.hasSources == false + ? context.l10n.searchNoSources + : context.l10n.scheduleNoSearchResults, + 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, + 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), + ), + ); + } + + 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) { + 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(() {}); + }, + ), + ), + ), + ); + }, + ), + ); + } + + 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 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( + 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} · ' + '${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, + ), + 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 showBusyMaxDatePicker( + 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, + accountLabels: { + for (final account in accounts) account.id: account.displayLabel, + }, + ); + 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: 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( + 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), + ), + 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, + ), + ), + 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} · ' + '${candidate.authenticatedAccountEmail ?? candidate.accountId}', + ), + 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 _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 { + 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( + BuildContext context, + WidgetRef ref, { + required String eventId, +}) async { + final detail = await ref + .read(calendarRepositoryProvider) + .loadEventDetail(eventId); + if (!context.mounted || detail == null) return; + final sources = await ref.read(calendarSourcesStreamProvider.future); + if (!context.mounted) return; + 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.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) { + ScheduleViewMode.month => _MonthView( + anchor: anchor, + items: items, + onSelectDate: onSelectDate, + onOpen: onOpen, + onToggleTask: onToggleTask, + ), + ScheduleViewMode.year => _YearView( + anchor: anchor, + items: items, + onSelectDate: onSelectDate, + onModeChanged: onModeChanged, + ), + ScheduleViewMode.week || ScheduleViewMode.day => _AndroidTimeGrid( + key: ValueKey('android-${mode.name}-time-grid'), + anchor: anchor, + days: mode == ScheduleViewMode.day ? 1 : 7, + firstWeekday: _firstWeekday(context), + items: items, + onOpen: onOpen, + ), + ScheduleViewMode.agenda => _AgendaList( + items: items, + onOpen: onOpen, + onToggleTask: onToggleTask, + ), + }; +} + +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; + @override + Widget build(BuildContext context) { + 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: agendaItems.length, + separatorBuilder: (_, _) => const SizedBox(height: 6), + itemBuilder: (context, 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 = + 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 || + (displayDate == null && previousDisplayDate != null) || + (displayDate != null && + (previousDisplayDate == null || + !_sameDay(displayDate, previousDisplayDate)))) + Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), + child: Text( + displayDate == null + ? context.l10n.noDate + : '${isToday ? '${context.l10n.today} · ' : ''}' + '${DateFormat.yMMMMEEEEd().format(displayDate)}', + 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( + searchCriteria != null + ? scheduleSearchResultText( + context, + item, + searchCriteria!, + searchQuery, + ) + : '$time · ${item.sourceName ?? item.provider.displayName}', + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + onTap: () => onOpen(item), + ), + ], + ), + ); + }, + ); + } +} + +class _AndroidTimeGrid extends StatelessWidget { + const _AndroidTimeGrid({ + super.key, + required this.anchor, + required this.days, + required this.firstWeekday, + required this.items, + required this.onOpen, + }); + + static const _axisWidth = 54.0; + static const _minuteHeight = .72; + + final DateTime anchor; + final int days; + final int firstWeekday; + final List items; + final ValueChanged onOpen; + + @override + Widget build(BuildContext context) { + final start = days == 1 + ? DateTime(anchor.year, anchor.month, anchor.day) + : ScheduleRange.week(anchor, firstWeekday: firstWeekday).start; + final dates = [ + for (var index = 0; index < days; index++) + DateTime(start.year, start.month, start.day + index), + ]; + return LayoutBuilder( + builder: (context, constraints) { + 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; + 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( + width: contentWidth, + height: constraints.maxHeight, + child: Column( + children: [ + SizedBox( + height: dateHeaderHeight, + 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, + ), + ), + ), + ], + ), + ), + SizedBox( + height: allDayHeight, + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + SizedBox( + width: _axisWidth, + child: Center( + child: Text( + context.l10n.allDay, + style: Theme.of(context).textTheme.labelSmall, + ), + ), + ), + 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 + : 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)), + ), + ), + ], + ), + ), + ); + + 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 { + 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) { + 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) { + final velocity = details.primaryVelocity ?? 0; + if (velocity.abs() < 180) return; + onSelectDate( + DateTime(anchor.year, anchor.month + (velocity < 0 ? 1 : -1)), + ); + }, + 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, + ), + ), + ), + SizedBox( + height: agendaHeight, + child: _AgendaList( + items: ScheduleProjection.itemsForDay(items, anchor), + onOpen: onOpen, + onToggleTask: onToggleTask, + ), + ), + ], + ), + ), + ); + }, + ); + } +} + +class _YearView extends StatelessWidget { + const _YearView({ + 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: const SliverGridDelegateWithMaxCrossAxisExtent( + maxCrossAxisExtent: 330, + mainAxisExtent: 245, + crossAxisSpacing: 8, + mainAxisSpacing: 8, + ), + itemCount: 12, + itemBuilder: (context, index) { + final month = DateTime(anchor.year, index + 1); + return Card( + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: () { + onSelectDate(month); + onModeChanged(ScheduleViewMode.month); + }, + child: Padding( + padding: const EdgeInsets.all(8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + DateFormat.MMMM().format(month), + style: Theme.of(context).textTheme.titleSmall, + ), + const SizedBox(height: 4), + Expanded( + child: IgnorePointer( + child: _AndroidMonthGrid( + displayedMonth: month, + selectedDate: anchor, + items: items, + onDaySelected: (_) {}, + compact: true, + ), + ), + ), + ], + ), + ), + ), + ); + }, + ); +} + +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 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), + ]; + 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; + 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( + decoration: BoxDecoration( + color: selected ? scheme.secondaryContainer : null, + border: compact + ? null + : Border.all(color: Theme.of(context).dividerColor, width: .35), + ), + child: compact + ? FittedBox(fit: BoxFit.scaleDown, child: content) + : Center(child: content), + ), + ); + } +} + +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( + _provider, + _draft.recurrence, + baseDate: _draft.start, + ).frequency; + bool _recurrenceChanged = false; + bool _saving = false; + bool _allowPop = false; + bool _recoveryLoaded = false; + bool _recoveryWritesBlocked = false; + Future _recoveryIo = Future.value(); + 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(); + _guests.dispose(); + _categories.dispose(); + super.dispose(); + } + + 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 PopScope( + canPop: _allowPop || !_hasPendingEdits, + onPopInvokedWithResult: (didPop, _) async { + if (didPop || _saving || _allowPop) return; + if (await _confirmDiscardChanges() && mounted) { + await _clearRecovery(finalCleanup: true); + 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), + ), + title: Text( + _draft.eventId == null + ? context.l10n.newEvent + : context.l10n.editEvent, + ), + actions: [ + TextButton( + onPressed: _saving || !_canEdit ? null : _save, + child: Text(context.l10n.save), + ), + ], + ), + 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)), + ), + const SizedBox(height: 12), + 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, + clearShowAs: true, + clearVisibilityOrSensitivity: true, + ), + ); + }, + ), + SwitchListTile( + contentPadding: EdgeInsets.zero, + title: Text(context.l10n.allDay), + value: _draft.allDay, + onChanged: !_canEdit + ? null + : (value) => setState( + () => _draft = _draft.copyWith(allDay: value), + ), + ), + _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), + 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, + decoration: InputDecoration( + labelText: context.l10n.location, + prefixIcon: const Icon(Icons.place_outlined), + ), + onChanged: (v) => + setState(() => _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) => + setState(() => _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: !_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, + ), + ], + 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, + 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), + 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; + 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: attendeeEdit.attendees, + attendeesChanged: attendeeEdit.changed, + categories: categories, + categoriesChanged: !listEquals(categories, widget.draft.categories), + ); + if (_reminderMinutes != null) { + final value = _reminderMinutes!; + draft = draft.copyWith( + reminders: _eventReminders(_provider, value), + remindersChanged: true, + clearReminders: value == 0, + ); + } + if (_recurrenceChanged) { + final rule = _simpleRecurrenceRule(_frequency, start); + draft = draft.copyWith( + recurrence: rule.repeats + ? EventRecurrenceCodec.encode( + _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 ((_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); + } + ref + .read( + pendingCalendarMutationSyncRequesterForAccountProvider( + draft.accountId, + ), + ) + .request(); + if (mounted) { + await _clearRecovery(finalCleanup: true); + 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); + } + } + + 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 && + 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, + ); + ref + .read( + pendingCalendarMutationSyncRequesterForAccountProvider( + draft.accountId, + ), + ) + .request(); + if (mounted) { + await _clearRecovery(finalCleanup: true); + 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 _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(_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; + } + + Future _requestClose() async { + if (!_hasPendingEdits || await _confirmDiscardChanges()) { + if (!mounted) return; + await _clearRecovery(finalCleanup: true); + 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() ?? ''); + 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) || + (version != 2 && version != 3) || + !identityMatches) { + await _clearRecovery(); + return; + } + final start = DateTime.tryParse(map['start']?.toString() ?? ''); + final end = DateTime.tryParse(map['end']?.toString() ?? ''); + if (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; + 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( + 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 { + // A malformed/locked recovery entry must never block the editor. + } finally { + _recoveryLoaded = true; + } + } + + Future _persistRecovery() async { + if (!_recoveryLoaded || + !_hasPendingEdits || + _allowPop || + _recoveryWritesBlocked) { + return; + } + final payload = jsonEncode({ + 'v': 3, + 'savedAt': DateTime.now().toIso8601String(), + 'eventId': widget.draft.eventId, + '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 _enqueueRecovery(Future Function() operation) { + final result = _recoveryIo.then((_) => operation()); + _recoveryIo = result.catchError((_) {}); + return result.catchError((_) {}); + } +} + +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) => + 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), + ), + ), + enabled: onChanged != null, + onTap: onChanged == null + ? null + : () async { + final date = await showBusyMaxDatePicker( + 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, { + 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) { + 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, 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}', +}; + +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, + 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; + +int _firstWeekday(BuildContext context) { + return BusyMaxWeekPreferencesScope.firstWeekdayOf(context); +} + +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}, + ], + }; +} + +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_schedule_search_filters.dart b/lib/src/android/presentation/android_schedule_search_filters.dart new file mode 100644 index 00000000..215c3f1d --- /dev/null +++ b/lib/src/android/presentation/android_schedule_search_filters.dart @@ -0,0 +1,260 @@ +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'; +import 'android_date_picker.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 showBusyMaxDatePicker( + 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_settings_screen.dart b/lib/src/android/presentation/android_settings_screen.dart new file mode 100644 index 00000000..6da02e64 --- /dev/null +++ b/lib/src/android/presentation/android_settings_screen.dart @@ -0,0 +1,2245 @@ +import 'dart:async'; +import 'dart:convert'; +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'; + +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 '../../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'; +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 '../../l10n/week_preferences_scope.dart'; +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}); + + @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)), + ), + 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( + 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), + ), + ], + ), + ), + 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( + 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, + ) + .then( + (_) => ref + .read(notificationReconcilerProvider) + .reconcile(), + ), + ), + ), + 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()), + ), + ListTile( + leading: const Icon(Icons.monitor_heart_outlined), + title: Text(context.l10n.diagnostics), + onTap: () => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => const AndroidDiagnosticsScreen(), + ), + ), + ), + ], + ), + _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, + ), + 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(), + ), + ), + ), + ], + ), + ], + ), + ); + } + + 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; + final localNetworkDenied = context.l10n.nextcloudOperationDenied; + 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 || !mounted) return; + final uri = Uri.tryParse(server); + if (uri != null && await requiresAndroidLocalNetworkAccess(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); + 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) { + return showDialog<(String, String)>( + context: context, + builder: (_) => _AndroidAppleCredentialsDialog(fixedEmail: fixedEmail), + ); + } + + 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.inbox_outlined), + title: Text(context.l10n.nextcloudSchedulingInbox), + onTap: () => Navigator.pop(context, 'scheduling'), + ), + 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( + 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); + _requestQueuedCalendarSync(source); + } + } 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); + _requestQueuedCalendarSync(source); + } 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 == '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 == + 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); + _requestQueuedCalendarSync(source); + } + } 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.people_outline), + title: Text(context.l10n.nextcloudSharing), + onTap: () => Navigator.pop(context, 'sharing'), + ), + 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 == '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, + 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]), + ), + ], + ), + ), + ); + } + + 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 + .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, + }) { + return showDialog( + context: context, + builder: (_) => _AndroidTextPromptDialog( + title: title, + label: label, + initialValue: initialValue, + helper: helper, + ), + ); + } + + 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; + + void _message(String message) { + if (!mounted) return; + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(message))); + } +} + +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; +} + +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, + 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; + 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..95813d7d --- /dev/null +++ b/lib/src/android/presentation/android_tasks_screen.dart @@ -0,0 +1,2494 @@ +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'; +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'; +import '../android_notifications.dart'; +import 'android_date_picker.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, + taskCompletion: key.completed + ? ScheduleTaskCompletion.all + : ScheduleTaskCompletion.open, + 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 []; + 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, + )) { + _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) { + if (value == 'clear-completed') { + unawaited(_clearCompleted()); + return; + } + 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), + ), + if (_listId != null && + (selectedAccount?.provider == BusyProvider.google || + selectedAccount?.provider == BusyProvider.nextcloud)) + PopupMenuItem( + value: 'clear-completed', + child: Text(context.l10n.clearCompleted), + ), + ], + ), + ], + 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} · ${accountLabels[list.accountId] ?? list.accountId}', + overflow: TextOverflow.ellipsis, + ), + ), + ], + 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( + heroTag: 'android-tasks-add', + 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 _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) + .firstOrNull; + final selected = + current ?? + await selectAndroidTaskList( + 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 = + 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, + Map accountLabels = const {}, +}) { + 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), + subtitle: Text(accountLabels[list.accountId] ?? list.accountId), + onTap: () => Navigator.pop(sheetContext, list), + ), + ], + ), + ), + ); +} + +Future showAndroidTaskEditor( + BuildContext context, + WidgetRef ref, { + TaskScheduleItem? task, + TaskListEntity? creationList, + BusyProvider? creationProvider, + 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, + builder: (_) => AndroidTaskEditor( + accountId: task?.accountId ?? creationList!.accountId, + provider: task?.provider ?? creationProvider!, + task: entity, + creationList: creationList, + initialDue: initialDue, + accountLabel: account?.displayLabel, + listLabel: selectedList?.title, + ), + ), + ); +} + +class AndroidTaskEditor extends ConsumerStatefulWidget { + const AndroidTaskEditor({ + super.key, + required this.accountId, + required this.provider, + 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(); +} + +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 _url = TextEditingController( + text: _draft.taskUrl, + ); + late final TextEditingController _categories = TextEditingController( + text: _draft.categories.join(', '), + ); + 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; + + 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, + }; + + 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(); + } + + @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; + final canWrite = _canWrite; + return PopScope( + canPop: _allowPop || !_hasPendingEdits, + onPopInvokedWithResult: (didPop, _) async { + if (didPop || _saving || _allowPop) return; + if (await _confirmDiscardChanges() && mounted) { + await _clearRecovery(finalCleanup: true); + 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), + ), + title: Text( + widget.task == null ? context.l10n.newTask : context.l10n.editTask, + ), + actions: [ + TextButton( + onPressed: _saving || !canWrite ? null : _save, + child: Text(context.l10n.save), + ), + ], + ), + 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), + ), + ), + const SizedBox(height: 12), + TextField( + controller: _title, + enabled: canWrite, + autofocus: widget.task == null, + decoration: InputDecoration(labelText: context.l10n.title), + onChanged: (v) => + setState(() => _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) => + setState(() => _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.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, + ), + 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, + ), + 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, + ), + 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, + ), + ], + if (capabilities.supportsCategories) ...[ + const SizedBox(height: 12), + TextField( + controller: _categories, + enabled: canWrite, + decoration: InputDecoration(labelText: context.l10n.categories), + onChanged: (v) => setState( + () => _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, + enabled: canWrite, + decoration: InputDecoration(labelText: context.l10n.location), + onChanged: (v) => + setState(() => _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) => + setState(() => _draft = _draft.copyWith(taskUrl: value)), + ), + ], + if (capabilities.supportsClassification) + DropdownButtonFormField( + initialValue: _draft.classification, + decoration: InputDecoration( + labelText: context.l10n.classification, + ), + 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), + ), + ), + 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], + ], + ), + ), + ), + ), + ], + 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 (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 && 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), + ], + ), + ), + ); + } + + Future _pickDue() async { + final current = DateTime.tryParse(_draft.dueDate ?? '') ?? DateTime.now(); + final value = await showBusyMaxDatePicker( + context: context, + initialDate: current, + firstDate: DateTime(1970), + lastDate: DateTime(2200), + ); + if (value != null) { + setState(() => _draft = _draft.copyWith(dueDate: _date(value))); + } + } + + Widget _taskHierarchySection( + TaskHierarchySnapshot hierarchy, + TaskCollectionCapabilities capabilities, + ) => Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + context.l10n.subtasks, + style: Theme.of(context).textTheme.titleMedium, + ), + 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 _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, + ), + ), + ); + } + + Future _toggleSubtask(TaskSubtaskEntity subtask) async { + try { + final repository = ref.read( + tasksRepositoryForAccountProvider(widget.accountId), + ); + 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, + subtask.id, + TaskPatchInput({ + 'status': subtask.completed ? 'needsAction' : 'completed', + }), + ); + } + } on Object catch (error) { + if (mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('$error'))); + } + } + } + + 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 { + 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 showBusyMaxDatePicker( + 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 showBusyMaxDatePicker( + 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 showBusyMaxDatePicker( + 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 showBusyMaxDatePicker( + 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 { + setState(_syncDraftFromControllers); + if (!_draftIsValid) return; + setState(() => _saving = true); + try { + await _persistTaskDraft(); + if (mounted) { + await _clearRecovery(finalCleanup: true); + 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); + } + } + + 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; + 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); + _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 = rule.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) { + await _clearRecovery(finalCleanup: true); + 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 _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 { + 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, + lists, + title: context.l10n.list, + accountLabels: { + widget.accountId: widget.accountLabel ?? widget.accountId, + }, + ); + 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 { + 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); + } 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(finalCleanup: true); + 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['v'] != 2 || + map['baseTitle'] != _initialDraft.title || + map['accountId'] != widget.accountId || + map['taskListId'] != _initialDraft.taskListId) { + await _clearRecovery(); + return; + } + 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'], + 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 []) + .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, + ); + 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: provider!, rule: creationRule), + ); + } + 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 = + creationRule?.frequency ?? _taskRecurrence().frequency; + _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 || + _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({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 { + 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 showBusyMaxDatePicker( + 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) => + '$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); + 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( + fullscreenDialog: true, + builder: (_) => AndroidTaskEditor( + accountId: accountId, + provider: account.provider, + task: TaskEntity.fromRow(row), + accountLabel: account.displayLabel, + listLabel: list?.title, + ), + ), + ); +} + +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 _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/app/app_bootstrap.dart b/lib/src/app/app_bootstrap.dart index 3838ac32..55631383 100644 --- a/lib/src/app/app_bootstrap.dart +++ b/lib/src/app/app_bootstrap.dart @@ -10,6 +10,8 @@ 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/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'; @@ -44,6 +46,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'; @@ -119,7 +122,7 @@ final networkReconnectSyncCoordinatorProvider = final baseHttpClientProvider = Provider((ref) { final client = ConnectivityAwareHttpClient( - inner: http.Client(), + inner: NativeTerminatingHttpClient(), requireNetwork: ref .watch(networkConnectivityMonitorProvider) .requireNetwork, @@ -234,10 +237,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 +286,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 +395,7 @@ final davSettingsRepositoryProvider = Provider((ref) { return DavSettingsRepository( database: ref.watch(databaseProvider), onVisibilityChanged: (_) => - ref.read(notificationSchedulerProvider).checkNow(), + ref.read(notificationReconcilerProvider).reconcile(), ); }); @@ -452,7 +471,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 +487,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 +502,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 +517,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 +533,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 +635,7 @@ final syncEngineForAccountFactoryProvider = accountId: accountId, ), onConflictBlocked: ref - .read(desktopNotificationServiceProvider) + .read(operationalNotificationReporterProvider) .notifyConflict, ); }; @@ -649,10 +675,10 @@ final calendarSyncEngineForAccountFactoryProvider = accountId: accountId, ), onConflictBlocked: ref - .read(desktopNotificationServiceProvider) + .read(operationalNotificationReporterProvider) .notifyConflict, onNotificationScheduleChanged: () => - ref.read(notificationSchedulerProvider).checkNow(), + ref.read(notificationReconcilerProvider).reconcile(), ); }; }); @@ -671,10 +697,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 +716,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 +770,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 +815,7 @@ final allAccountsSyncRunnerProvider = Provider((ref) { .listSyncEligibleAccounts, syncAccount: syncAccount, onSyncFailure: ref - .read(desktopNotificationServiceProvider) + .read(operationalNotificationReporterProvider) .notifySyncFailure, onAccountSyncFailure: (accountId, error) => _markAccountReconnectRequiredForSyncError(ref, accountId, error), @@ -808,7 +841,7 @@ final calendarRepositoryProvider = Provider((ref) { nextcloudCollections: (accountId) => ref.read(nextcloudCollectionServiceProvider(accountId)), onNotificationScheduleChanged: () => - ref.read(notificationSchedulerProvider).checkNow(), + ref.read(notificationReconcilerProvider).reconcile(), ); }); @@ -834,7 +867,7 @@ final webCalSubscriptionServiceProvider = Provider(( secretStore: ref.watch(secretStoreProvider), httpTransport: ref.watch(webCalHttpTransportProvider), onNotificationScheduleChanged: () => - ref.read(notificationSchedulerProvider).checkNow(), + ref.read(notificationReconcilerProvider).reconcile(), ); }); @@ -852,7 +885,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 +1067,7 @@ final taskListsRepositoryProvider = Provider((ref) { ), onMutationQueued: ref.watch(pendingMutationSyncRequesterProvider)?.request, onNotificationScheduleChanged: () => - ref.read(notificationSchedulerProvider).checkNow(), + ref.read(notificationReconcilerProvider).reconcile(), ); }); @@ -1051,7 +1084,7 @@ final taskListsRepositoryForAccountProvider = .watch(pendingMutationSyncRequesterForAccountProvider(accountId)) .request, onNotificationScheduleChanged: () => - ref.read(notificationSchedulerProvider).checkNow(), + ref.read(notificationReconcilerProvider).reconcile(), ); }); @@ -1066,7 +1099,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 +1113,7 @@ final tasksRepositoryForAccountProvider = .watch(pendingMutationSyncRequesterForAccountProvider(accountId)) .request, onNotificationScheduleChanged: () => - ref.read(notificationSchedulerProvider).checkNow(), + ref.read(notificationReconcilerProvider).reconcile(), ); }); @@ -1096,7 +1129,7 @@ final pendingMutationSyncRequesterProvider = .read(accountSyncOperationsProvider) .syncTasks(accountId, full: false), onSyncFailure: ref - .watch(desktopNotificationServiceProvider) + .watch(operationalNotificationReporterProvider) .notifySyncFailure, onSyncError: (error) => _markAccountReconnectRequiredForSyncError(ref, accountId, error), @@ -1113,7 +1146,7 @@ final pendingMutationSyncRequesterForAccountProvider = .read(accountSyncOperationsProvider) .syncTasks(accountId, full: false), onSyncFailure: ref - .watch(desktopNotificationServiceProvider) + .watch(operationalNotificationReporterProvider) .notifySyncFailure, onSyncError: (error) => _markAccountReconnectRequiredForSyncError(ref, accountId, error), @@ -1130,7 +1163,7 @@ final pendingCalendarMutationSyncRequesterForAccountProvider = .read(accountSyncOperationsProvider) .syncCalendar(accountId, full: false), onSyncFailure: ref - .watch(desktopNotificationServiceProvider) + .watch(operationalNotificationReporterProvider) .notifySyncFailure, onSyncError: (error) => _markAccountReconnectRequiredForSyncError(ref, accountId, error), @@ -1156,7 +1189,7 @@ final pendingOpResolutionServiceForAccountProvider = .read(accountSyncOperationsProvider) .syncCalendar(accountId, full: false), onNotificationScheduleChanged: () => - ref.read(notificationSchedulerProvider).checkNow(), + ref.read(notificationReconcilerProvider).reconcile(), ); }); @@ -1182,7 +1215,7 @@ final syncSchedulerProvider = Provider((ref) { .listSyncEligibleAccounts, syncAccount: syncAccount, onSyncFailure: ref - .watch(desktopNotificationServiceProvider) + .watch(operationalNotificationReporterProvider) .notifySyncFailure, onAccountSyncFailure: (accountId, error) => _markAccountReconnectRequiredForSyncError(ref, accountId, error), @@ -1276,6 +1309,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..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, @@ -60,6 +63,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(), @@ -71,6 +75,7 @@ class AppSettings { themeModePreference: BusyMaxThemeModePreference.system, localeTag: null, timeFormatPreference: BusyMaxTimeFormatPreference.system, + firstDayOfWeekPreference: BusyMaxFirstDayOfWeekPreference.system, notifySyncFailures: true, notifyConflicts: true, notifyDueToday: false, @@ -162,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: @@ -195,6 +205,10 @@ class AppSettings { json['scheduleViewMode'], defaults.scheduleViewMode, ), + androidScheduleViewMode: _enumFromNameOrNull( + ScheduleViewMode.values, + json['androidScheduleViewMode'], + ), scheduleDayStartMinute: scheduleDayStartMinute, scheduleDayEndMinute: scheduleDayEndMinute, ); @@ -204,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; @@ -222,6 +237,7 @@ class AppSettings { final Map taskListScheduleVisibility; final ScheduleSidebarOrder sidebarOrder; final ScheduleViewMode scheduleViewMode; + final ScheduleViewMode? androidScheduleViewMode; final int scheduleDayStartMinute; final int scheduleDayEndMinute; @@ -235,6 +251,7 @@ class AppSettings { 'themeModePreference': themeModePreference.name, 'localeTag': localeTag, 'timeFormatPreference': timeFormatPreference.name, + 'firstDayOfWeekPreference': firstDayOfWeekPreference.name, 'notifySyncFailures': notifySyncFailures, 'notifyConflicts': notifyConflicts, 'notifyDueToday': notifyDueToday, @@ -253,6 +270,7 @@ class AppSettings { 'taskListScheduleVisibility': taskListScheduleVisibility, 'sidebarOrder': sidebarOrder.toJson(), 'scheduleViewMode': scheduleViewMode.name, + 'androidScheduleViewMode': androidScheduleViewMode?.name, 'scheduleDayStartMinute': scheduleDayStartMinute, 'scheduleDayEndMinute': scheduleDayEndMinute, }; @@ -263,6 +281,7 @@ class AppSettings { BusyMaxThemeModePreference? themeModePreference, Object? localeTag = _unset, BusyMaxTimeFormatPreference? timeFormatPreference, + BusyMaxFirstDayOfWeekPreference? firstDayOfWeekPreference, bool? notifySyncFailures, bool? notifyConflicts, bool? notifyDueToday, @@ -281,6 +300,7 @@ class AppSettings { Map? taskListScheduleVisibility, ScheduleSidebarOrder? sidebarOrder, ScheduleViewMode? scheduleViewMode, + Object? androidScheduleViewMode = _unset, int? scheduleDayStartMinute, int? scheduleDayEndMinute, bool clearLastDueTodayNotificationDate = false, @@ -297,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 @@ -325,6 +347,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 +389,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 { @@ -409,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, { @@ -474,6 +505,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/app/busymax_app.dart b/lib/src/app/busymax_app.dart index a5078ce0..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'; @@ -9,12 +10,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'; @@ -28,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); @@ -87,6 +92,9 @@ class BusyMaxApp extends LinuxBusyMaxApp { } class _BusyMaxAppState extends ConsumerState { + final _logger = RedactingLogger(Logger('LinuxBusyMaxApp')); + final _calendarOpenReadiness = DesktopCalendarOpenReadiness(); + late final BusyMaxSystemFirstWeekdayController _firstWeekdayController; BusyMaxTrayService? _trayService; bool? _lastHideOnClose; bool? _lastTrayEnabled; @@ -107,6 +115,9 @@ class _BusyMaxAppState extends ConsumerState { @override void initState() { super.initState(); + _firstWeekdayController = BusyMaxSystemFirstWeekdayController( + const LinuxFirstWeekdaySource(), + )..addListener(_weekPreferenceChanged); _headerBarConfigurationSynchronizer = BusyMaxHeaderBarConfigurationSynchronizer( ref.read(linuxHeaderBarServiceProvider), @@ -115,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) @@ -128,6 +144,10 @@ class _BusyMaxAppState extends ConsumerState { @override void dispose() { + _calendarOpenReadiness.dispose(); + _firstWeekdayController + ..removeListener(_weekPreferenceChanged) + ..dispose(); _headerBarConfigurationSynchronizer.dispose(); unawaited(_externalOpenSubscription?.cancel()); unawaited(_navigationSubscription?.cancel()); @@ -138,8 +158,34 @@ class _BusyMaxAppState extends ConsumerState { super.dispose(); } + void _weekPreferenceChanged() { + if (mounted) setState(() {}); + } + 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: @@ -152,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( @@ -335,11 +389,25 @@ 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: 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/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 dde90afa..c89da236 100644 --- a/lib/src/app/windows/windows_busymax_app.dart +++ b/lib/src/app/windows/windows_busymax_app.dart @@ -3,12 +3,16 @@ 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'; 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'; @@ -18,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(); @@ -67,16 +72,23 @@ 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() { super.initState(); WidgetsBinding.instance.addObserver(this); _clockPreference = WindowsClockPreference()..addListener(_platformChanged); + _firstWeekdayController = BusyMaxSystemFirstWeekdayController( + WindowsFirstWeekdaySource(), + )..addListener(_platformChanged); _activationSubscription = ref .read(desktopActivationServiceProvider) .activations @@ -103,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(); } @@ -112,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) { @@ -155,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', @@ -166,8 +238,12 @@ class _WindowsBusyMaxAppState extends ConsumerState @override void dispose() { + _calendarOpenReadiness.dispose(); WidgetsBinding.instance.removeObserver(this); _clockPreference.dispose(); + _firstWeekdayController + ..removeListener(_platformChanged) + ..dispose(); unawaited(_activationSubscription?.cancel()); unawaited(_navigationSubscription?.cancel()); unawaited(_appearanceSubscription?.cancel()); @@ -233,13 +309,23 @@ 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: 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/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/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/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/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/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 f3891502..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'; @@ -14,10 +15,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 +220,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 +253,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 +267,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 +287,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 +317,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 +329,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 +350,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,23 +365,25 @@ 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 responseBytes = await _readBoundedBody( - streamed, - token, - 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, @@ -410,30 +435,142 @@ 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 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, + ); + try { + return await Future.any([ + sendFuture, + connectTimeout.future, + cancellationFuture, + ]); + } on Object { + if (!abort.isCompleted) abort.complete(); + // 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(); + } + } + bool _isRetryableStatus(int statusCode) => statusCode == HttpStatus.tooManyRequests || statusCode == HttpStatus.serviceUnavailable || @@ -500,22 +637,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 +681,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/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/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/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/connectivity/network_connectivity_service.dart b/lib/src/features/connectivity/network_connectivity_service.dart index e2fc010b..2fa0c0a3 100644 --- a/lib/src/features/connectivity/network_connectivity_service.dart +++ b/lib/src/features/connectivity/network_connectivity_service.dart @@ -4,13 +4,26 @@ 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/http/terminating_http_client.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'; } @@ -194,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, @@ -206,10 +220,45 @@ final class ConnectivityAwareHttpClient extends http.BaseClient { @override Future send(http.BaseRequest request) async { - await _requireNetwork(); + 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) { + if (error is RequestNotDispatchedException) { + Error.throwWithStackTrace(error, stackTrace); + } + Error.throwWithStackTrace( + KnownUnsentRequestException( + kind: RequestPreDispatchFailureKind.connectivity, + cause: error, + ), + stackTrace, + ); + } + } + @override void close() { _inner.close(); 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/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_agenda_view.dart b/lib/src/features/schedule/presentation/schedule_agenda_view.dart index b06f6983..b4179f70 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,38 @@ class _ScheduleAgendaViewState extends State { @override Widget build(BuildContext context) { + 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); @@ -113,58 +151,74 @@ 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 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); + } + } + return [ + for (final entry in datedGroups.entries) + _AgendaSection( + title: _dayLabel(context, entry.key), + children: _entriesFor( + entry.value, + _AgendaHierarchyIndex(entry.value), + {}, + ), + ), + if (undated.isNotEmpty) + _AgendaSection( + title: context.l10n.noDate, + children: _entriesFor( + undated, + _AgendaHierarchyIndex(undated), + {}, + ), + ), + ]; } List _entriesFor( @@ -214,6 +268,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 @@ -324,6 +380,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), @@ -363,6 +428,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}); @@ -384,9 +456,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 +483,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..d287317f --- /dev/null +++ b/lib/src/features/schedule/presentation/schedule_search_filters.dart @@ -0,0 +1,308 @@ +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), + 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_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..16f863a3 --- /dev/null +++ b/lib/src/features/schedule/presentation/schedule_search_result_text.dart @@ -0,0 +1,99 @@ +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'; + +/// 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; + 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, + ScheduleSearchCriteria criteria, + String query, +) { + 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.dueDate}: ${taskDate(item.due!)}' + : start != null + ? '${l.startDate}: ${taskDate(start)}' + : l.searchNoDueDate + : 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..aa8dbf2e 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'; @@ -24,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'; @@ -59,6 +61,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'; @@ -148,6 +152,7 @@ class ScheduleWorkspace extends ConsumerStatefulWidget { const ScheduleWorkspace({ super.key, this.initialScope = ScheduleScope.all, + this.initialDate, this.initialTaskAccountId, this.initialTaskListId, this.initialTaskId, @@ -155,6 +160,8 @@ class ScheduleWorkspace extends ConsumerStatefulWidget { }); final ScheduleScope initialScope; + @visibleForTesting + final DateTime? initialDate; final String? initialTaskAccountId; final String? initialTaskListId; final String? initialTaskId; @@ -183,7 +190,15 @@ class _ScheduleWorkspaceState extends ConsumerState { var _sidebarCollapsed = false; var _searchActive = false; var _searchQuery = ''; + bool? _sidebarBeforeSearch; + ScheduleSearchCriteria? _searchCriteria; + ScheduleSearchCriteria? _initialSearchCriteria; + var _searchFirstWeekday = DateTime.monday; + List _searchSources = const []; + List _searchTaskLists = const []; + ScheduleSourceVisibility? _searchVisibility; final _searchController = TextEditingController(); + final _workspaceFocusNode = FocusNode(); var _fallbackSearchFocusRequest = 0; var _latestCanShowSidebar = false; var _latestAccounts = const []; @@ -216,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(); @@ -246,9 +264,22 @@ class _ScheduleWorkspaceState extends ConsumerState { unawaited(_headerBarSearchEvents?.cancel()); unawaited(_initialTaskTargetSubscription?.cancel()); _searchController.dispose(); + _workspaceFocusNode.dispose(); 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); @@ -270,7 +301,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 +341,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 +396,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 +404,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 +415,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 +463,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 +502,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 +579,7 @@ class _ScheduleWorkspaceState extends ConsumerState { isUnavailable: scheduleUnavailable, mode: displayMode, range: displayRange, - selectedDate: searchHasQuery + selectedDate: searchActive ? displayRange.start : _selectedDate, firstWeekday: _firstWeekday(context), @@ -566,25 +636,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 +664,9 @@ class _ScheduleWorkspaceState extends ConsumerState { _openItem( context, item, - visibleSources, + searchActive + ? sources + : visibleSources, globalPosition: globalPosition, ), ), @@ -604,7 +676,9 @@ class _ScheduleWorkspaceState extends ConsumerState { _setChecklistItemCompleted, canCreateEvent: writableSources.isNotEmpty, canCreateTask: canCreateTask, - searchActive: searchHasQuery, + searchActive: searchActive, + searchCriteria: _searchCriteria, + searchQuery: _searchQuery, ), ), ), @@ -668,7 +742,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 +809,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 +858,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 +909,7 @@ class _ScheduleWorkspaceState extends ConsumerState { return; case BusyMaxHeaderBarAction.sidebarToggle: if (!_latestCanShowSidebar) { + if (_searchActive) unawaited(_showSearchFilters()); return; } setState(() => _sidebarCollapsed = !_sidebarCollapsed); @@ -860,7 +939,7 @@ class _ScheduleWorkspaceState extends ConsumerState { if (_searchActive) { _closeSearch(); } else { - setState(() => _searchActive = true); + _openSearch(); _focusSearch(); } case BusyMaxHeaderBarAction.createEvent: @@ -953,14 +1032,120 @@ 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, 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.copyWith( + firstWeekday: _firstWeekday(context), + ), + ); + refresh?.call(); + }, + onClear: () { + setState( + () => _searchCriteria = _initialSearchCriteria?.copyWith( + firstWeekday: _firstWeekday(context), + ), + ); + refresh?.call(); + }, + ), + ), + ); + + Future _showSearchFilters() async { + if (_searchCriteria == null) return; + 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({ 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 +1157,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 +1173,7 @@ class _ScheduleWorkspaceState extends ConsumerState { taskListKeys: taskListKeys, taskListFilterActive: true, includeTasks: _scope != ScheduleScope.events, - showCompletedTasks: false, + taskCompletion: ScheduleTaskCompletion.open, ), ); final noDateTasks = repository.listNoDateTasks( @@ -998,7 +1183,7 @@ class _ScheduleWorkspaceState extends ConsumerState { taskListKeys: taskListKeys, taskListFilterActive: true, includeTasks: _scope != ScheduleScope.events, - showCompletedTasks: true, + taskCompletion: ScheduleTaskCompletion.all, ), ); final datedItems = await currentItems; @@ -1168,6 +1353,7 @@ class _ScheduleWorkspaceState extends ConsumerState { } void _goToToday() { + if (_searchActive) return; setState(() { _selectedDate = _day(DateTime.now()); if (_mode == ScheduleViewMode.agenda) { @@ -1180,6 +1366,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,28 +1425,28 @@ 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; } 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, @@ -1275,17 +1462,17 @@ class _ScheduleWorkspaceState extends ConsumerState { } void _next() { + if (_searchActive) return; if (_mode == ScheduleViewMode.agenda) { return; } 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, @@ -1306,7 +1493,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 +1514,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 +2703,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 +2860,8 @@ class _ScheduleBody extends StatelessWidget { required this.canCreateEvent, required this.canCreateTask, required this.searchActive, + this.searchCriteria, + this.searchQuery = '', }); final bool isLoading; @@ -2715,6 +2908,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 +2919,7 @@ class _ScheduleBody extends StatelessWidget { if (isLoading) { return const ScheduleLoadingState(); } - if (!hasAnySources) { + if (!hasAnySources && !searchActive) { return ScheduleNoSourcesState( hasAccounts: hasAccounts, onOpenSettings: onOpenSettings, @@ -2732,6 +2927,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 +2998,8 @@ class _ScheduleBody extends StatelessWidget { ), ), ScheduleViewMode.agenda => ScheduleAgendaView( + searchCriteria: searchCriteria, + searchQuery: searchQuery, range: range, items: items, hasMoreOverdueTasks: hasMoreAgendaOverdueTasks, @@ -3042,8 +3242,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 f1b0a1fd..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), @@ -265,6 +275,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/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/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..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({ @@ -43,6 +48,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 +67,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) { @@ -83,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); @@ -160,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 f0d4b3a5..977ba95b 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'; @@ -68,8 +69,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; @@ -78,11 +98,20 @@ 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); 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 +124,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 +146,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': @@ -161,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 = ?', @@ -185,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 { @@ -196,9 +260,93 @@ 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 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 ?? '{}'), + ); + for (final field in acknowledged.keys) { + baseline[field] = serverSnapshot[field]; + } + await (_database.update( + _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() + : Value(serverList.updated!.toUtc().toIso8601String()), + updatedAtUtc: Value(_now()), + ), + ); + } + return true; } Future _deleteTaskList(PendingOp op) async { @@ -255,7 +403,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; } @@ -265,7 +413,7 @@ class PendingOpsReplayer { }); } - Future _rebaseDependentTaskEdits( + Future _rebaseDependentTaskMutations( PendingOp completedOp, TaskDto serverTask, ) async { @@ -282,14 +430,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, @@ -326,6 +477,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()), ); @@ -627,6 +785,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; @@ -694,10 +854,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 +869,45 @@ class PendingOpsReplayer { nextAttemptAtUtc: DateTime.utc(9999, 12, 31), lastErrorCode: errorCode, lastErrorMessage: errorMessage, + state: _isCreationOp(op) ? 'failed' : null, ); - if (op.operation == 'create_task') { - await _setPendingOpState(op.id, 'failed'); - } } - Future _claimTaskCreate(PendingOp op) async { + 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', + ); + } + + bool _hasUnknownCreationOutcome(int statusCode) { + return statusCode == 408 || statusCode >= 500; + } + + Future _claimCreation(PendingOp op) async { final query = _database.update(_database.pendingOps) ..where( (row) => @@ -733,12 +923,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 +970,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, ); @@ -815,31 +1003,32 @@ 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); 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 +1040,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) { @@ -890,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; } @@ -976,6 +1188,9 @@ class PendingOpsReplayer { String _now() => _nowUtc().toIso8601String(); } +const _childTaskConflictBaselineKey = + '_busymaxChildTaskConflictBaselineUpdatedUtc'; + const _pendingOpReferenceKeys = { 'id', 'parent', 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/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/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/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_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/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/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/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/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/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..f157df1e --- /dev/null +++ b/lib/src/l10n/week_preferences_scope.dart @@ -0,0 +1,183 @@ +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, { + this.readTimeout = const Duration(seconds: 2), + }) : super(null) { + WidgetsBinding.instance.addObserver(this); + _subscription = _source.changes.listen( + (_) => unawaited(refresh()), + onError: (Object _) {}, + ); + unawaited(refresh()); + } + + 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().timeout(readTimeout); + } on Object { + result = null; + } + if (_disposed || sequence != _refreshSequence) return; + 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 + void didChangeAppLifecycleState(AppLifecycleState state) { + if (state == AppLifecycleState.resumed) unawaited(refresh()); + } + + @override + void didChangeLocales(List? locales) => unawaited(refresh()); + + @override + void dispose() { + _disposed = true; + _refreshSequence++; + if (!_ready.isCompleted) _ready.complete(); + WidgetsBinding.instance.removeObserver(this); + unawaited(_subscription?.cancel()); + _source.dispose(); + super.dispose(); + } +} + +/// 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({ + 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 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, + DateTime(2026, DateTime.january, 5 + effective - DateTime.monday), + ); + return preference == BusyMaxFirstDayOfWeekPreference.system + ? context.l10n.systemDefaultResolved(weekday) + : weekday; +} 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/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/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/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/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/lib/src/platform/linux_first_weekday_source.dart b/lib/src/platform/linux_first_weekday_source.dart new file mode 100644 index 00000000..df866053 --- /dev/null +++ b/lib/src/platform/linux_first_weekday_source.dart @@ -0,0 +1,45 @@ +import 'dart:async'; + +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', + ), + 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 { + try { + return await _channel.invokeMethod('getFirstWeekday'); + } on MissingPluginException { + return null; + } on PlatformException { + return null; + } + } + + @override + void dispose() { + unawaited( + _channel.invokeMethod('cancelFirstWeekdayReads').catchError((_) {}), + ); + } +} 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/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_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_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_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..9e544ba5 --- /dev/null +++ b/lib/src/schedule/schedule_search_criteria.dart @@ -0,0 +1,138 @@ +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, + int? firstWeekday, + Set? sourceIds, + Set? taskListKeys, + }) => ScheduleSearchCriteria( + type: type ?? this.type, + date: date ?? this.date, + taskCompletion: taskCompletion ?? this.taskCompletion, + taskDueState: taskDueState ?? this.taskDueState, + person: type == ScheduleSearchType.tasks ? '' : person ?? this.person, + location: location ?? this.location, + customStart: customStart ?? this.customStart, + customEnd: customEnd ?? this.customEnd, + referenceDate: referenceDate, + firstWeekday: firstWeekday ?? this.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..eae1a5ce --- /dev/null +++ b/lib/src/schedule/schedule_search_match.dart @@ -0,0 +1,220 @@ +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, + ]) { + final identity = []; + for (final key in const [ + 'displayName', + 'name', + 'email', + 'address', + 'cn', + 'uri', + ]) { + final value = person[key]; + 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) 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, +}; + +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) { + for (final identity in scheduleEventPeople(item)) { + yield* identity; + } + } + 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 personTerms = _terms(filters.person); + if (personTerms.isNotEmpty && + !scheduleEventPeople( + item, + ).any((identity) => _matchesPerson(identity, personTerms))) { + 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) { + final personTerms = _terms(person); + for (final identity in scheduleEventPeople(item)) { + if (_matchesPerson(identity, personTerms)) { + return _snippet(identity.join(' · '), personTerms); + } + } + } + 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).toInt(); + 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..0a2d4c99 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; @@ -39,6 +40,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 +61,19 @@ class _WindowsSchedulePageState extends ConsumerState { final _searchFocusNode = FocusNode(); var _selectedDate = DateTime.now(); var _query = ''; + bool _searchActive = false; + bool? _sourcePaneBeforeSearch; + ScheduleSearchCriteria? _searchCriteria; + ScheduleSearchCriteria? _initialSearchCriteria; + var _firstWeekday = DateTime.monday; + 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 +87,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 +118,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,10 +134,24 @@ class _WindowsSchedulePageState extends ConsumerState { void dispose() { _searchDebounce?.cancel(); _searchController.dispose(); + _searchFocusNode.removeListener(_searchFocusChanged); _searchFocusNode.dispose(); 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; @@ -136,7 +165,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 +287,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 +304,21 @@ 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( + firstWeekday: _firstWeekday, + 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 +390,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, + searchCriteria: _searchCriteria, + searchQuery: _query, + ); + } + return _ScheduleModeView( + firstWeekday: _firstWeekday, + 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 +729,76 @@ 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: _firstWeekday, + 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, + 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 (!_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 +807,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 +850,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 +860,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 +872,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 +894,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, @@ -782,8 +928,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), @@ -897,6 +1049,38 @@ class _WindowsSchedulePageState extends ConsumerState { required List taskLists, required ScheduleSourceVisibility visibility, }) { + if (_searchActive && _searchCriteria != null) { + return showDialog( + context: context, + builder: (context) => StatefulBuilder( + 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), + ), + ], + ); + }, + ), + ); + } final visibleCalendars = {...visibility.visibleCalendarSourceIds}; final visibleTaskLists = {...visibility.visibleTaskListKeys}; return showDialog( @@ -908,6 +1092,7 @@ class _WindowsSchedulePageState extends ConsumerState { width: 520, height: math.min(MediaQuery.sizeOf(context).height - 180, 620), child: WindowsScheduleSourcePane( + firstWeekday: BusyMaxWeekPreferencesScope.firstWeekdayOf(context), selectedDate: _selectedDate, accounts: accounts, calendarSources: sources, @@ -1443,6 +1628,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, @@ -1460,6 +1646,7 @@ class _ScheduleModeView extends StatelessWidget { }); final ScheduleViewMode mode; + final int firstWeekday; final DateTime selectedDate; final ScheduleRange range; final List items; @@ -1493,6 +1680,7 @@ class _ScheduleModeView extends StatelessWidget { dayEndMinute: dayEndMinute, ), ScheduleViewMode.month => WindowsScheduleMonthView( + firstWeekday: firstWeekday, onReschedule: onReschedule, selectedDate: selectedDate, range: range, @@ -1502,6 +1690,7 @@ class _ScheduleModeView extends StatelessWidget { onSelectDate: onSelectDate, ), ScheduleViewMode.year => WindowsScheduleYearView( + firstWeekday: firstWeekday, selectedDate: selectedDate, items: items, locale: locale, @@ -1511,7 +1700,6 @@ class _ScheduleModeView extends StatelessWidget { items: items, locale: locale, onOpen: onOpen, - groupByDate: true, onLoadMore: onLoadMoreAgenda, ), }; @@ -1519,23 +1707,30 @@ 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, - required this.groupByDate, this.onLoadMore, }); + final ScheduleSearchCriteria? searchCriteria; + final String searchQuery; 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), @@ -1547,9 +1742,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), @@ -1563,7 +1761,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), @@ -1578,6 +1776,8 @@ class _AgendaList extends StatelessWidget { rows.add( _ScheduleItemCard( item: item, + searchCriteria: searchCriteria, + searchQuery: searchQuery, locale: locale, onPressed: () => onOpen(item), ), @@ -1605,6 +1805,7 @@ class WindowsScheduleMonthView extends StatelessWidget { const WindowsScheduleMonthView({ super.key, required this.selectedDate, + required this.firstWeekday, required this.onReschedule, required this.range, required this.items, @@ -1614,6 +1815,7 @@ class WindowsScheduleMonthView extends StatelessWidget { }); final DateTime selectedDate; + final int firstWeekday; final ScheduleRescheduleCallback onReschedule; final ScheduleRange range; final List items; @@ -1648,9 +1850,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, ), ), @@ -1804,12 +2006,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; @@ -1832,7 +2036,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), @@ -1849,9 +2053,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, @@ -2044,7 +2248,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 +2268,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 +2302,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..5b3c2b9e --- /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).toDouble(), + 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_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/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/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/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.h b/linux/runner/first_weekday.h new file mode 100644 index 00000000..7c66da6a --- /dev/null +++ b/linux/runner/first_weekday.h @@ -0,0 +1,56 @@ +#ifndef BUSYMAX_FIRST_WEEKDAY_H_ +#define BUSYMAX_FIRST_WEEKDAY_H_ + +#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. 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"); + + if (relative_weekday < 1 || relative_weekday > 7) return std::nullopt; + + 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. + } 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; +} + +} // 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 +} + +#endif // BUSYMAX_FIRST_WEEKDAY_H_ 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 5ba952ec..5e57ed35 100644 --- a/linux/runner/my_application.cc +++ b/linux/runner/my_application.cc @@ -1,15 +1,19 @@ #include "time_picker.h" #include "my_application.h" +#include "first_weekday_preference.h" #include #include #include #include +#include #include #include #include #include +#include #include +#include #include "flutter/generated_plugin_registrant.h" constexpr char kApplicationDisplayName[] = "BusyMax"; @@ -28,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; @@ -110,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; @@ -425,29 +434,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 +454,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 +520,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); @@ -4285,6 +4220,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) { @@ -4659,11 +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) { + 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); @@ -4675,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; @@ -4817,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( @@ -5204,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); @@ -5328,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..8f2e4c4b --- /dev/null +++ b/linux/runner/tests/first_weekday_preference_test.cc @@ -0,0 +1,495 @@ +#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"); + } + } + + 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_; } + + 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); + g_autoptr(GSettings) settings = TestSettings(schemas); + 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"); + } + SetSetting(settings, "tuesday"); + portal.set_value("default"); + Check(Read(reader) == 7, + "portal default bypasses stale direct GSettings and uses locale"); + portal.set_value("not-a-weekday"); + 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); + 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); + TestSupersededRead(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/first_weekday_test.cc b/linux/runner/tests/first_weekday_test.cc new file mode 100644 index 00000000..15b38f17 --- /dev/null +++ b/linux/runner/tests/first_weekday_test.cc @@ -0,0 +1,81 @@ +#include "../first_weekday.h" + +#include +#include +#include +#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(); + } +} + +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); + 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. + + RequireLocale("LC_TIME", LC_TIME, "C.UTF-8"); + assert(BusyMaxReadFirstWeekday() == 7); // Sunday. + return 0; +} 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/.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..a93f0824 --- /dev/null +++ b/packages/busymax_android_platform/android/build.gradle.kts @@ -0,0 +1,84 @@ +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("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") + 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..027ba03d --- /dev/null +++ b/packages/busymax_android_platform/android/src/main/kotlin/io/busystack/busymax_android_platform/BusymaxAndroidPlatformPlugin.kt @@ -0,0 +1,807 @@ +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 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 +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.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 +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 + +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() + } + } + } +} + +internal data class MicrosoftSilentFailure( + val code: String, + val message: String, +) + +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 +} + +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, + 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, + 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 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 + private val engineId = UUID.randomUUID().toString() + @Volatile private var detached = false + @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")) + } + } + 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 + context = binding.applicationContext + channel = MethodChannel(binding.binaryMessenger, METHOD_CHANNEL) + eventChannel = EventChannel(binding.binaryMessenger, EVENT_CHANNEL) + channel.setMethodCallHandler(this) + eventChannel.setStreamHandler(this) + activePlugins.add(this) + settingsReceiverLifecycle.attach() + } + + override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) { + detached = true + EngineOwnedAccountGateRegistry.releaseOwned(engineId) + activePlugins.remove(this) + channel.setMethodCallHandler(null) + eventChannel.setStreamHandler(null) + settingsReceiverLifecycle.detach() + 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) { + activityListenerLifecycle.attach(binding) + activity = binding.activity + captureActivation(binding.activity.intent) + } + + override fun onDetachedFromActivityForConfigChanges() = detachActivity() + override fun onDetachedFromActivity() = detachActivity() + + private fun detachActivity() { + activityListenerLifecycle.detach() + 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)) + "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, + ) + "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) + "releaseOwnedAccountGates" -> { + EngineOwnedAccountGateRegistry.releaseOwned(engineId) + result.success(null) + } + "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 firstWeekday(): Int? = + androidxFirstWeekday() + + 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, + // 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) { + 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 MicrosoftAccountMissingException() + val parameters = AcquireTokenSilentParameters.Builder() + .withScopes(scopes).forAccount(account).fromAuthority(account.authority).build() + postSuccess(result, msalResult(application.acquireTokenSilent(parameters))) + } catch (error: Exception) { + val failure = classifyMicrosoftSilentFailure(error) + postError(result, failure.code, failure.message, 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!! + 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) + } + } + 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) + 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) + 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 { + 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.") + postSuccess(pending.result, 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}.") + } + postSuccess(pending.result, 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 { + try { + 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) { + 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 + 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 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..73fe204d --- /dev/null +++ b/packages/busymax_android_platform/android/src/test/kotlin/io/busystack/busymax_android_platform/BusymaxAndroidPlatformPluginTest.kt @@ -0,0 +1,143 @@ +package io.busystack.busymax_android_platform + +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 +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 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() + val call = MethodCall("unsupported", null) + val mockResult: MethodChannel.Result = Mockito.mock(MethodChannel.Result::class.java) + 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") + } + + @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/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 00000000..db77bb4b Binary files /dev/null and b/packages/busymax_android_platform/example/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ 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 00000000..17987b79 Binary files /dev/null and b/packages/busymax_android_platform/example/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/packages/busymax_android_platform/example/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/packages/busymax_android_platform/example/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 00000000..09d43914 Binary files /dev/null and b/packages/busymax_android_platform/example/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ 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 00000000..d5f1c8d3 Binary files /dev/null and b/packages/busymax_android_platform/example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ 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 00000000..4d6372ee Binary files /dev/null and b/packages/busymax_android_platform/example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ 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..691bbe72 --- /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.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 new file mode 100644 index 00000000..4a9f6b74 --- /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.3 + +# 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..5cd7e76f --- /dev/null +++ b/packages/busymax_android_platform/lib/busymax_android_platform.dart @@ -0,0 +1,336 @@ +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; + + /// Dart weekday numbering (Monday = 1 through Sunday = 7), or unavailable. + Future getFirstWeekday() => + _methodChannel.invokeMethod('getFirstWeekday'); + + 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}); + + /// 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', + ); + 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..386349d6 --- /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.4' + +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..aee5ac62 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.4" diff --git a/pubspec.yaml b/pubspec.yaml index 9752b2cf..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.1 +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/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. 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..45be3c50 --- /dev/null +++ b/test/app/first_day_of_week_test.dart @@ -0,0 +1,310 @@ +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); + }); + + 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(); + 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 { + 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 b0114b2d..15ae0b1f 100644 --- a/test/app/native_ui_audit_test.dart +++ b/test/app/native_ui_audit_test.dart @@ -1344,6 +1344,49 @@ void main() { expect(confirmBody, isNot(contains('return AlertDialog('))); }); + 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', + ).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(); final service = File( @@ -1945,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/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/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/dav/http/dav_http_transport_test.dart b/test/dav/http/dav_http_transport_test.dart index 3252ad84..ea4cbce2 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'; @@ -5,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; @@ -294,6 +296,356 @@ 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', + ), + ), + ); + await Future.delayed(Duration.zero); + 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; + 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 { + _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/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/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/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 new file mode 100644 index 00000000..974d618a --- /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 the BusyMax calendar popover', ( + tester, + ) async { + 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 openPicker(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(); + 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, 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, 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 openPicker(labels.endDate); + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pumpAndSettle(); + expect(state.value, beforeCancel); + expect(find.byType(BusyMaxContentPopoverSurface), 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 new file mode 100644 index 00000000..b328d6d8 --- /dev/null +++ b/test/features/schedule/presentation/schedule_search_platform_test.dart @@ -0,0 +1,643 @@ +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/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'; +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/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'; +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 'package:intl/intl.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); + expect( + find.text(DateFormat.yMMMMEEEEd('en').format(DateTime(2040, 2, 15))), + findsOneWidget, + ); + if (platform == 'linux') { + 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' + ? 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 switch, then verify repository/settings + // stayed intact. + final checkbox = platform == 'linux' + ? find + .descendant( + of: panelFinder, + matching: find.byType(YaruSwitchListTile), + ) + .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(YaruSwitchListTile), + ) + .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, + ); + 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( + 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'), + 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); + 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); + 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); + }, + ); + + 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 { + 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, + ValueListenable? systemWeekday, +}) 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(); + }); + 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: app), + ); + return (db: db, container: container); +} 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..957dfa04 --- /dev/null +++ b/test/features/schedule/presentation/schedule_search_result_text_test.dart @@ -0,0 +1,177 @@ +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); + }); + + 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', + ]); + }); + + 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_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 e8593304..c62e0e78 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'; @@ -44,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 [], @@ -79,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, @@ -103,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, @@ -462,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), @@ -522,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), @@ -567,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, @@ -711,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 [], @@ -1071,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), @@ -1104,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 [], @@ -1168,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 [], @@ -1307,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), @@ -1338,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), @@ -2802,6 +2838,260 @@ 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, + firstWeekday: DateTime.monday, + ), + 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('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 { @@ -2876,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, @@ -2990,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: (_, _) {}, @@ -3114,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: (_, _) {}, @@ -3142,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) { @@ -3472,40 +3774,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', @@ -3526,6 +3794,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', @@ -4217,7 +4506,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 +4865,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 +4891,7 @@ void main() { expect( source, contains( - 'showNoDateTasks: searchHasQuery || ' + 'showNoDateTasks: searchActive || ' '_mode != ScheduleViewMode.agenda', ), ); @@ -4612,7 +4903,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_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/schedule/presentation/schedule_workspace_states_test.dart b/test/features/schedule/presentation/schedule_workspace_states_test.dart index a98ea017..a3c52f8b 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, '', ); @@ -256,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'), @@ -266,6 +289,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 +334,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_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 1d0eda40..43ee3d81 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,538 @@ 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); + + 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', + '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); + }); + } + 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', + () { + 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 +604,8 @@ void main() { filters: const ScheduleFilters( accountIds: {'account'}, query: 'future budget', - showCompletedTasks: true, + ignoreDateRange: true, + taskCompletion: ScheduleTaskCompletion.all, ), ); @@ -112,7 +642,7 @@ void main() { filters: const ScheduleFilters( accountIds: {'account'}, includeCalendarEvents: false, - showCompletedTasks: true, + taskCompletion: ScheduleTaskCompletion.all, showNoDateTasks: true, ), ); diff --git a/test/features/settings/presentation/settings_screen_test.dart b/test/features/settings/presentation/settings_screen_test.dart index 3459cb96..b2b6e55b 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 { @@ -1199,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/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 3c011d7e..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() { @@ -583,6 +586,345 @@ 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( + '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 { @@ -609,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({ @@ -640,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, ), @@ -676,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"}', @@ -694,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', @@ -708,6 +1079,7 @@ TasksCompanion _localTask( localDirty: Value(localDirty), pendingDelete: Value(pendingDelete), pendingMove: Value(pendingMove), + microsoftChecklistItemsJson: Value(checklistItemsJson), createdLocalAtUtc: _now, updatedLocalAtUtc: _now, ); @@ -715,25 +1087,33 @@ TasksCompanion _localTask( Future _enqueueBlockedOp( AppDatabase database, { + String id = 'op-1', + String accountId = 'account', String entityType = 'task', required String operation, String? operationType, String? taskListId, 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), 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..5e600b1c 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'; @@ -406,7 +408,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 +428,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 +474,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, @@ -627,6 +729,682 @@ 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( + '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 { + 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 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', + () 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 { @@ -952,6 +1730,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 +2532,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,27 +2612,70 @@ 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 = []; final taskPatchFields = >[]; final createParentTaskIds = []; final moveParentTaskIds = []; + final taskListPatchTitles = []; GoogleTasksApiError? patchTaskListError; - GoogleTasksApiError? createTaskError; + Object? createTaskListError; + Object? createTaskError; GoogleTasksApiError? deleteTaskError; GoogleTasksApiError? clearCompletedError; GoogleTasksApiError? moveTaskError; TaskListDto? remoteTaskList; TaskDto? remoteTask; + TaskDto? remoteTaskAfterMove; + DateTime? taskListPatchResultUpdated; + final taskListPageUpdatedMins = []; 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); } @@ -1732,7 +2689,19 @@ 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, + updated: taskListPatchResultUpdated, + ); + if (persistTaskListPatches) remoteTaskList = result; + return result; } @override @@ -1829,7 +2798,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 @@ -1875,6 +2847,7 @@ class _FakeTaskRemoteClient implements TaskRemoteClient { DateTime? updatedMin, bool showAssigned = false, }) async { + taskListPageUpdatedMins.add(updatedMin); return remoteTasksPage; } } @@ -1882,6 +2855,7 @@ class _FakeTaskRemoteClient implements TaskRemoteClient { class _ChecklistTaskRemoteClient extends _FakeTaskRemoteClient implements TaskChecklistRemoteClient { final checklistCalls = []; + Object? createChecklistItemError; @override Future createChecklistItem({ @@ -1891,6 +2865,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, @@ -1951,6 +2927,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/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/flutter_test_config.dart b/test/flutter_test_config.dart new file mode 100644 index 00000000..9e1c22d8 --- /dev/null +++ b/test/flutter_test_config.dart @@ -0,0 +1,22 @@ +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('io.busystack.busymax/first_weekday'), + MethodChannel('busymax/windows_weekday'), + ]) { + messenger.setMockMethodCallHandler(channel, (call) async { + if (call.method == 'getFirstWeekday') return null; + return null; + }); + } +} 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/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 { 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..4de6d9f3 --- /dev/null +++ b/test/platform/android/android_adaptive_app_test.dart @@ -0,0 +1,1902 @@ +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/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'; +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/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'; +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'; +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(); + + 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; + late Map secureStorage; + + setUp(() { + secureStorage = {}; + messenger.setMockMethodCallHandler(platformChannel, (call) async { + return switch (call.method) { + 'takeInitialActivation' => null, + 'googleAuthorizationAvailable' => false, + 'microsoftAuthorizationAvailable' => false, + _ => null, + }; + }); + messenger.setMockMethodCallHandler(eventChannel, (_) 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(() { + messenger.setMockMethodCallHandler(platformChannel, null); + messenger.setMockMethodCallHandler(eventChannel, null); + messenger.setMockMethodCallHandler(secureStorageChannel, 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); + addTearDown(tester.platformDispatcher.clearTextScaleFactorTestValue); + + 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('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 { + 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(); + } + }); + + 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); + addTearDown(tester.platformDispatcher.clearTextScaleFactorTestValue); + + 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.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').hitTestable().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').hitTestable().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); + 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(); + 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').hitTestable().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) + .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( + '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 { + 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 { + 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( + '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( + '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 { + 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); + 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); + }); + + 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(EventEditorDraft draft) => navigator.push( + MaterialPageRoute( + builder: (_) => + AndroidEventEditor(sources: const [sourceA, sourceB], draft: draft), + ), + ); + open(initial); + 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); + 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(); + 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(); + + 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(rewritten['start'], initial.start!.toIso8601String()); + 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, + CloudCalendarClient? calendarClient, +}) async { + final database = AppDatabase.memoryForTests(); + if (seedDatabase != null) await seedDatabase(database); + if (populated) await _seedPopulatedSchedule(database); + final notifications = AndroidNotificationService( + database: database, + settings: () => settings, + ); + final container = ProviderContainer( + overrides: [ + buildConfigProvider.overrideWithValue(BuildConfig.forAndroid()), + databaseProvider.overrideWithValue(database), + 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), + if (calendarClient != null) + calendarRemoteApiClientForAccountProvider.overrideWith( + (ref, accountId) => calendarClient, + ), + initialAppSettingsProvider.overrideWithValue(settings), + localSettingsStoreProvider.overrideWithValue(MemorySettingsStore()), + allAccountsSyncRunnerProvider.overrideWithValue(() async {}), + notificationReconcilerProvider.overrideWithValue( + CallbackNotificationReconciler(() async {}), + ), + androidNotificationServiceProvider.overrideWithValue(notifications), + davTaskCollectionCapabilitiesProvider.overrideWith( + (ref, key) async => nextcloudTaskCollectionCapabilities, + ), + ], + ); + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: const AndroidBusyMaxApp(), + ), + ); + await tester.pumpAndSettle(); + 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.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', + notes: Value(_longScheduleDetailsNotes), + 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( + 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.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( + 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, + ), + ); +} + +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); + + 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_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_date_picker_test.dart b/test/platform/android/android_date_picker_test.dart new file mode 100644 index 00000000..a111f816 --- /dev/null +++ b/test/platform/android/android_date_picker_test.dart @@ -0,0 +1,254 @@ +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 { + 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); + }); +} + +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/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..d9bac69d --- /dev/null +++ b/test/platform/android/android_local_network_host_test.dart @@ -0,0 +1,80 @@ +import 'dart:io'; + +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); + }); + + 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 new file mode 100644 index 00000000..82f492a1 --- /dev/null +++ b/test/platform/android/android_notification_policy_test.dart @@ -0,0 +1,156 @@ +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('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, + remainsDisplayed: false, + ), + isTrue, + ); + expect( + shouldKeepAndroidReminder( + effectiveAt: 100, + 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, 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( + 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( + 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_notification_reconciliation_test.dart b/test/platform/android/android_notification_reconciliation_test.dart new file mode 100644 index 00000000..bc672ce1 --- /dev/null +++ b/test/platform/android/android_notification_reconciliation_test.dart @@ -0,0 +1,529 @@ +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, + title: 'Visible meeting', + ); + 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 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', + quietHoursEnd: '07:00', + ); + await _insertReminder( + database, + generation: 'generation-1', + scheduledAt: requested, + ); + + 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('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 { + 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, + title: const AndroidNotificationStrings().dueTodayTitle, + ), + ); + }, + ); +} + +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? title, 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, + title: title, + 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; +} 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..62c93a2c --- /dev/null +++ b/test/platform/android/android_platform_channel_test.dart @@ -0,0 +1,89 @@ +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)); + }, + ); + + test('worker teardown releases every gate owned by its engine', () async { + final platform = BusyMaxAndroidPlatform(methodChannel: channel); + + await platform.releaseOwnedAccountGates(); + + expect(calls.single.method, 'releaseOwnedAccountGates'); + }); +} 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..0fed9b7a --- /dev/null +++ b/test/platform/android/android_settings_persistence_test.dart @@ -0,0 +1,37 @@ +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); + }); + + 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/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_autostart_service_test.dart b/test/platform/linux_autostart_service_test.dart index f8767b7d..a153fb6d 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); + } + }, + ); + }, skip: !Platform.isLinux); + for (final entryCase in <({String contents, bool enabled})>[ (contents: 'Hidden=true', enabled: false), (contents: 'Hidden=false', enabled: true), @@ -160,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/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/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', () { 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 { diff --git a/test/platform/windows/windows_clock_preference_test.dart b/test/platform/windows/windows_clock_preference_test.dart index a5dfe137..1399d0d8 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,74 @@ 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); + }, + ); + + 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(); + }, + ); } 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/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/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/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..d9a17b24 --- /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.4 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.4 \ + --expected-dart 3.13.3 + +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.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/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..cc998ffa 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/') || @@ -162,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; } 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)); + } +} 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)) { 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_