From bf98600229d803f24ca5295e408379d1537aad08 Mon Sep 17 00:00:00 2001 From: albert Date: Tue, 22 Sep 2026 17:18:38 -0700 Subject: [PATCH 01/11] Bump version to v0.2.4 --- android/app/build.gradle.kts | 4 ++-- docs/android_setup.md | 2 +- linux/io.busystack.busymax.metainfo.xml | 5 +++++ pubspec.yaml | 2 +- snap/snapcraft.yaml | 2 +- test/app/caldav_release_metadata_test.dart | 8 ++++---- test/platform/android/android_adaptive_app_test.dart | 10 +++++----- windows/runner/Runner.rc | 4 ++-- 8 files changed, 21 insertions(+), 16 deletions(-) diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 4d0adc57..2ff64421 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -59,12 +59,12 @@ android { minSdk = 24 targetSdk = 37 // Keep Android's required monotonic package code separate from the - // public 0.2.3 version declared in pubspec.yaml. When using split APKs, + // public 0.2.4 version declared in 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 = 3 + versionCode = 4 versionName = flutter.versionName manifestPlaceholders["busymaxMsalRedirectHost"] = applicationId!! manifestPlaceholders["busymaxMsalSignatureHash"] = msalSignatureHash diff --git a/docs/android_setup.md b/docs/android_setup.md index 50f685e3..fb33633a 100644 --- a/docs/android_setup.md +++ b/docs/android_setup.md @@ -1,7 +1,7 @@ # Android setup and provider registration BusyMax Android is a beta application with package ID -`io.busystack.busymax`, version `0.2.3`, minimum API 24, and compile/target +`io.busystack.busymax`, version `0.2.4`, 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. diff --git a/linux/io.busystack.busymax.metainfo.xml b/linux/io.busystack.busymax.metainfo.xml index 9b537b76..6721c994 100644 --- a/linux/io.busystack.busymax.metainfo.xml +++ b/linux/io.busystack.busymax.metainfo.xml @@ -100,6 +100,11 @@ + + +

Beta maintenance release.

+
+

Beta maintenance release.

diff --git a/pubspec.yaml b/pubspec.yaml index 8067b5de..306cc0e6 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.3 +version: 0.2.4 environment: sdk: ^3.12.0 diff --git a/snap/snapcraft.yaml b/snap/snapcraft.yaml index b6b81efd..edb59b17 100644 --- a/snap/snapcraft.yaml +++ b/snap/snapcraft.yaml @@ -1,6 +1,6 @@ name: busymax title: BusyMax -version: "0.2.3" +version: "0.2.4" summary: Calendar and task manager description: | BusyMax is a Linux desktop calendar and task manager. diff --git a/test/app/caldav_release_metadata_test.dart b/test/app/caldav_release_metadata_test.dart index 067d7555..fe61ce41 100644 --- a/test/app/caldav_release_metadata_test.dart +++ b/test/app/caldav_release_metadata_test.dart @@ -29,12 +29,12 @@ void main() { RegExp(r' Date: Tue, 29 Sep 2026 02:45:11 -0700 Subject: [PATCH 02/11] Update Flutter to 3.47.5 and Dart to 3.13.4 across toolchain, tests, and documentation. --- .github/workflows/flutter-android.yml | 2 +- .github/workflows/flutter-linux.yml | 6 +++--- .github/workflows/flutter-windows.yml | 2 +- README.md | 4 ++-- android/gradle.properties | 2 +- android/settings.gradle.kts | 2 +- docs/android_setup.md | 4 ++-- docs/development.md | 6 +++--- docs/windows_packaging.md | 2 +- docs/windows_release_checklist.md | 2 +- .../example/pubspec.lock | 2 +- .../busymax_android_platform/pubspec.yaml | 2 +- pubspec.lock | 2 +- test/tool/flutter_linux_workflow_test.dart | 6 +++--- test/tool/flutter_sdk_verifier_test.dart | 20 +++++++++---------- test/tool/flutter_windows_workflow_test.dart | 6 +++--- tool/android/check_prerequisites.sh | 8 ++++---- tool/windows/check_prerequisites.ps1 | 12 +++++------ 18 files changed, 45 insertions(+), 45 deletions(-) diff --git a/.github/workflows/flutter-android.yml b/.github/workflows/flutter-android.yml index 0d58c6ce..339a68e2 100644 --- a/.github/workflows/flutter-android.yml +++ b/.github/workflows/flutter-android.yml @@ -29,7 +29,7 @@ jobs: uses: subosito/flutter-action@v2 with: channel: stable - flutter-version: '3.47.4' + flutter-version: '3.47.5' cache: true - name: Install Android API and Build Tools 37 diff --git a/.github/workflows/flutter-linux.yml b/.github/workflows/flutter-linux.yml index ff0cd727..f5d9513e 100644 --- a/.github/workflows/flutter-linux.yml +++ b/.github/workflows/flutter-linux.yml @@ -42,7 +42,7 @@ jobs: uses: subosito/flutter-action@v2 with: channel: stable - flutter-version: '3.47.4' + flutter-version: '3.47.5' cache: true - name: Verify pinned Flutter and bundled Dart @@ -54,8 +54,8 @@ jobs: "$dart_executable" tool/verify_flutter_sdk.dart \ --flutter "$flutter_executable" \ --dart "$dart_executable" \ - --expected-flutter 3.47.4 \ - --expected-dart 3.13.3 + --expected-flutter 3.47.5 \ + --expected-dart 3.13.4 printf 'BUSYMAX_FLUTTER_EXECUTABLE=%s\n' "$flutter_executable" >> "$GITHUB_ENV" printf 'BUSYMAX_DART_EXECUTABLE=%s\n' "$dart_executable" >> "$GITHUB_ENV" diff --git a/.github/workflows/flutter-windows.yml b/.github/workflows/flutter-windows.yml index afeb81ec..71e0b220 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.4' + flutter-version: '3.47.5' cache: true - name: Enable Windows desktop diff --git a/README.md b/README.md index 158c0e8c..4a7c7dc9 100644 --- a/README.md +++ b/README.md @@ -87,8 +87,8 @@ differences and limitations are summarized in the ### Android development build Android support targets API 37 with a minimum API of 24 and uses the explicit -entrypoint `lib/main_android.dart`. It requires Flutter 3.47.4, bundled Dart -3.13.3, JDK 17, Android platform 37, and Build Tools 37.x. +entrypoint `lib/main_android.dart`. It requires Flutter 3.47.5, bundled Dart +3.13.4, JDK 17, Android platform 37, and Build Tools 37.x. ```sh tool/android/check_prerequisites.sh diff --git a/android/gradle.properties b/android/gradle.properties index fbfdfa1b..3c5590f7 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -1,6 +1,6 @@ org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError android.useAndroidX=true -# Flutter 3.47.4 still casts the current AGP extension to the legacy variant +# Flutter 3.47.5 still casts the current AGP extension to the legacy variant # API, so this one compatibility flag is required until the pinned Flutter # Gradle plugin migrates. Kotlin compilation itself remains AGP 9 built-in. android.newDsl=false diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts index 1086b33e..582e1ae8 100644 --- a/android/settings.gradle.kts +++ b/android/settings.gradle.kts @@ -20,7 +20,7 @@ pluginManagement { plugins { id("dev.flutter.flutter-plugin-loader") version "1.0.0" id("com.android.application") version "9.2.0" apply false - // Flutter 3.47.4 validates this minimum even though AGP 9 performs the + // Flutter 3.47.5 validates this minimum even though AGP 9 performs the // actual built-in Kotlin compilation. id("org.jetbrains.kotlin.android") version "2.4.0" apply false } diff --git a/docs/android_setup.md b/docs/android_setup.md index fb33633a..5852bc8f 100644 --- a/docs/android_setup.md +++ b/docs/android_setup.md @@ -2,12 +2,12 @@ BusyMax Android is a beta application with package ID `io.busystack.busymax`, version `0.2.4`, minimum API 24, and compile/target -API 37. The supported toolchain is Flutter 3.47.4, its bundled Dart 3.13.3, +API 37. The supported toolchain is Flutter 3.47.5, its bundled Dart 3.13.4, 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 +toolchain validation sees the supported language level. Flutter 3.47.5's own Gradle integration still casts the Android application extension to the legacy type, so this pinned project retains `android.newDsl=false`. Enabling the new DSL was exercised and reproducibly fails in Flutter's Gradle plugin with an diff --git a/docs/development.md b/docs/development.md index a6aae1cf..672146ce 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.4** and the Dart **3.13.3** SDK +BusyMax development and CI use Flutter **3.47.5** and the Dart **3.13.4** 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.4, prepare the checkout in this order: +After installing Flutter 3.47.5, 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.4 with Windows desktop enabled; +- Flutter 3.47.5 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/windows_packaging.md b/docs/windows_packaging.md index 351ccea7..683afa53 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.4 and bundled Dart 3.13.3, Visual Studio x64 +The script verifies Flutter 3.47.5 and bundled Dart 3.13.4, 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 7498fa1f..16c3732d 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.4 and its bundled Dart 3.13.3. +- [ ] A clean checkout uses Flutter 3.47.5 and its bundled Dart 3.13.4. - [ ] Dependency resolution, localization generation, and Drift generation follow [Development](development.md) and leave committed outputs clean. - [ ] The non-writing formatting check, analyzer, normal tests, and diff --git a/packages/busymax_android_platform/example/pubspec.lock b/packages/busymax_android_platform/example/pubspec.lock index 691bbe72..0400b7e6 100644 --- a/packages/busymax_android_platform/example/pubspec.lock +++ b/packages/busymax_android_platform/example/pubspec.lock @@ -288,4 +288,4 @@ packages: version: "3.2.0" sdks: dart: ">=3.13.3 <4.0.0" - flutter: ">=3.47.4" + flutter: ">=3.47.5" diff --git a/packages/busymax_android_platform/pubspec.yaml b/packages/busymax_android_platform/pubspec.yaml index 386349d6..dd05c74c 100644 --- a/packages/busymax_android_platform/pubspec.yaml +++ b/packages/busymax_android_platform/pubspec.yaml @@ -5,7 +5,7 @@ repository: https://github.com/BusyStack/BusyMax environment: sdk: ^3.12.0 - flutter: '>=3.47.4' + flutter: '>=3.47.5' dependencies: flutter: diff --git a/pubspec.lock b/pubspec.lock index aee5ac62..e94d4ef2 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1477,4 +1477,4 @@ packages: version: "0.1.3" sdks: dart: ">=3.12.0 <4.0.0" - flutter: ">=3.47.4" + flutter: ">=3.47.5" diff --git a/test/tool/flutter_linux_workflow_test.dart b/test/tool/flutter_linux_workflow_test.dart index 553d3667..dcf435b9 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.4'")); + expect(workflow, contains("flutter-version: '3.47.5'")); 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.4')); - expect(workflow, contains('--expected-dart 3.13.3')); + expect(workflow, contains('--expected-flutter 3.47.5')); + expect(workflow, contains('--expected-dart 3.13.4')); 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 0a00b11d..6bdf5e4f 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.4'); - expect(report['dartVersion'], '3.13.3'); + expect(report['flutterVersion'], '3.47.5'); + expect(report['dartVersion'], '3.13.4'); 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.3'); + expect(report['dartVersion'], '3.13.4'); 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.3'); + await _writeExecutable(siblingDart, dartVersion: '3.13.4'); 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.4'); - await _writeExecutable(bundledDart, dartVersion: '3.13.3'); + await _writeExecutable(flutter, flutterVersion: '3.47.5'); + await _writeExecutable(bundledDart, dartVersion: '3.13.4'); return _ToolchainFixture( root: root, flutter: flutter, @@ -133,9 +133,9 @@ final class _ToolchainFixture { '--dart', (dart ?? bundledDart).path, '--expected-flutter', - '3.47.4', + '3.47.5', '--expected-dart', - '3.13.3', + '3.13.4', ], environment: environment, runInShell: Platform.isWindows, @@ -172,7 +172,7 @@ Future _writeExecutable( if (Platform.isWindows) { final output = flutterVersion != null ? '{"frameworkVersion":"$flutterVersion",' - '"dartSdkVersion":"3.13.3"}' + '"dartSdkVersion":"3.13.4"}' : '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.3"}' + '"dartSdkVersion":"3.13.4"}' : '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 cc85b646..fb5de489 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.4'")); + expect(workflow, contains("flutter-version: '3.47.5'")); 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.4'")); - expect(prerequisiteScript, contains("dartVersion -ne '3.13.3'")); + expect(prerequisiteScript, contains("flutterVersion -ne '3.47.5'")); + expect(prerequisiteScript, contains("dartVersion -ne '3.13.4'")); expect(prerequisiteScript, isNot(contains('Get-Command dart'))); expect(prerequisiteScript, isNot(contains(r'$flutterBin -ne $dartBin'))); }); diff --git a/tool/android/check_prerequisites.sh b/tool/android/check_prerequisites.sh index d9a17b24..0bd21fdb 100755 --- a/tool/android/check_prerequisites.sh +++ b/tool/android/check_prerequisites.sh @@ -14,7 +14,7 @@ done flutter_bin="${BUSYMAX_FLUTTER_EXECUTABLE:-$(command -v flutter || true)}" if [[ -z "$flutter_bin" || ! -x "$flutter_bin" ]]; then - echo 'Flutter is missing. Install Flutter 3.47.4 and put it on PATH.' >&2 + echo 'Flutter is missing. Install Flutter 3.47.5 and put it on PATH.' >&2 exit 1 fi flutter_bin="$(readlink -f "$flutter_bin")" @@ -26,8 +26,8 @@ fi "$dart_bin" "$repo_root/tool/verify_flutter_sdk.dart" \ --flutter "$flutter_bin" \ --dart "$dart_bin" \ - --expected-flutter 3.47.4 \ - --expected-dart 3.13.3 + --expected-flutter 3.47.5 \ + --expected-dart 3.13.4 java_version="$(java -version 2>&1 | sed -n '1s/.*version "\([^"]*\)".*/\1/p')" if [[ "$java_version" != 17.* ]]; then @@ -81,6 +81,6 @@ if $require_signing; then done fi -echo 'PASSED: Flutter 3.47.4, bundled Dart 3.13.3, JDK 17, Android API 37, and Build Tools 37.x are available.' +echo 'PASSED: Flutter 3.47.5, bundled Dart 3.13.4, JDK 17, Android API 37, and Build Tools 37.x are available.' echo "Flutter: $flutter_bin" echo "Android SDK: $sdk_root" diff --git a/tool/windows/check_prerequisites.ps1 b/tool/windows/check_prerequisites.ps1 index 14dcce9e..f3330a64 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.4 ` - --expected-dart 3.13.3 + --expected-flutter 3.47.5 ` + --expected-dart 3.13.4 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.4') { - throw "BusyMax requires Flutter 3.47.4; found $flutterVersion." +if ($flutterVersion -ne '3.47.5') { + throw "BusyMax requires Flutter 3.47.5; found $flutterVersion." } -if ($dartVersion -ne '3.13.3') { - throw "Flutter 3.47.4 must provide Dart 3.13.3; found $dartVersion." +if ($dartVersion -ne '3.13.4') { + throw "Flutter 3.47.5 must provide Dart 3.13.4; found $dartVersion." } $vswhere = Join-Path ${env:ProgramFiles(x86)} 'Microsoft Visual Studio\Installer\vswhere.exe' if (-not (Test-Path -LiteralPath $vswhere)) { From 5986de8f07a9845826dbf5a27cfae34be2c3dd16 Mon Sep 17 00:00:00 2001 From: Albert G Date: Fri, 2 Oct 2026 01:35:17 -0700 Subject: [PATCH 03/11] Fix/essential correctness and usability (#19) * Add tests for providers * Add tests for CalDAV attachment URI and event URL projection * Add tests for Windows Microsoft event and task handling * Add tests for Microsoft calendar and task features * Add tests for Microsoft task details and attachment handling * Add Android dialogs for event attachments and recurrence editing * Add Microsoft task and event attachment providers * Preserve Microsoft meeting body HTML during edits to maintain meeting state * Handle non-list attachments in Google Calendar mapper to ensure consistent data structure * Add support for event attachments in Microsoft Calendar API client * Add Microsoft Todo API support for linked resources and task attachments * Add support for conference links in CalDAV event projections * Add event attachment links support in schedule items * Add Windows dialogs for cloud availability and event attachments * Add cloud calendar range coverage service and enhance event retrieval logic * Add support for rich text descriptions in event editor and enhance availability dialog * Add Linux event attachments dialog and implement attachment download functionality * Add Google task assignment policy and source link handling * Add multilingual support for linked resources and attachments in event management * Enhance live provider testing documentation for cloud-range synchronization, guest availability checks, and task-source navigation * Add provider tests: * Add unit tests for Microsoft meeting HTML preservation and encoded meeting URL parsing. * Add test coverage for Google Calendar attachment mutations and private-copy `iCalUID` import replaying. * Add test coverage for Microsoft Calendar event attachments, large file upload sessions, and series exception snapshots. * Add tests for delegated/shared primary calendar access, shared consent OAuth scope retention, and restricted private item projection. * Add tests for Microsoft To-Do file attachment uploads and strict parsing of malformed detail collection responses. * Update Microsoft Calendar mapping tests to ensure category names are not treated as provider color IDs. * Add test coverage for moved recurrence exception replay and cancelled occurrence persistence during iCal import. * * Refactor `cloudCoverageCompleteFor` and `ensureCloudCoverage` to accept schedule filters for source-aware range coverage checks. * Ensure cached schedule items are returned immediately while background cloud coverage sync completes. * Update schedule event detail interval labels to display formatted local time rather than raw event time zone strings. * Fix Microsoft Teams meeting HTML description replay during patch operations to prevent duplicate and stale content. * Enhance Microsoft sync engine to correctly preserve owner-context shared calendars and isolate explicit search sources during range coverage fetches. * Update task details pane test to trigger attachment loading by key. * * Add test coverage for confirming Microsoft event attachment deletion across native platforms. * Add test coverage for Android event details to verify local time projection and timezone display handling. * Add unit tests for preserving unsupported Google popup reminder overrides during edits and removals. * Add validation tests for the Android recurrence dialog to prevent saving invalid intervals. * Update tenant ID initialization and attachment widget key assumptions in Android adaptive app tests. * * Add tenant ID parameter to Microsoft account setup in Windows event preservation tests. * Add time zone display assertions to Windows schedule commands tests. * Refactor task attachment finder to use widget key in Windows task editor safety tests. * * Add Microsoft shared calendar support on Android, including interactive OAuth consent and settings UI options. * Add support for uploading and deleting attachments for Microsoft events and To Do tasks on Android. * Enhance Google event reminder management on Android with support for multiple custom popup reminders. * Improve recurrence dialog input validation for interval and count, and correct date boundary constraints. * Update schedule range coverage checks to account for active search filters. * Restrict Microsoft free/busy querying capability checks to work/school tenant accounts. * Enhance Microsoft shared calendar support, attachment uploads, and provider API handling * Support Microsoft shared primary calendars, delegated mailbox routing, and shared calendar OAuth consent. * Implement file attachment upload sessions for Microsoft Calendar events and Microsoft To-Do tasks. * Add `PrivateCalendarImportClient` and `changeEventAttachmentReferences` to Google Calendar API client. * Refactor Microsoft online meeting HTML parsing to cleanly separate provider blocks using balanced HTML tag inspection. * Determine Microsoft free/busy availability eligibility using explicit tenant IDs rather than decoding Graph access tokens. * Add `getSeriesExceptionSnapshot` to query Microsoft recurring event exception identities. * Harden Microsoft Graph API collection and attachment JSON deserialization against malformed payloads. * Support detached recurrence exceptions in iCalendar import * Add support for importing detached and cancelled recurrence exceptions for Google and Microsoft calendar destinations. * Validate exception timezones, interval durations, and date/time type compatibility during import preparation. * Parse and map modified exception properties including title, description, location, start/end times, and reminders to `ImportedEventException`. * Refactor cloud coverage tracking and add privacy masking for shared Microsoft events * Refactor cloud coverage logic in `ScheduleRepository` to be filter-aware, non-blocking, and debounced with rate-limiting. * Include cloud coverage state changes in `watchChanges()` stream emissions. * Mask title, event details, and attachments for private or owner-restricted Microsoft shared calendar events and disable their edit/delete capabilities. * * Add `microsoftSharedCalendarAuthorizationHeader` support to `AccountTokenBroker`. * Add `storedCalendarEventOverlapsUtcRange` utility in `stored_temporal_projection` to check cached event overlaps against UTC provider windows. * Add shared Microsoft calendar support, attachment management, and import exception handling * Add support for opening, syncing, and managing permissions for shared Microsoft primary calendars. * Implement file attachment upload and deletion for Microsoft events and To-Do tasks. * Handle recurring event import exceptions and Google iCal UID imports in calendar pending operations replayer. * Preserve Microsoft online meeting HTML subtrees during description editing and guard against unsafe body updates. * Enforce write permissions and private item access checks on shared Microsoft calendars across mutations and notifications. * Update schedule event details time formatting to display device-local time with offset and timezone labels. * Refactor cloud calendar range coverage and sync engine to support source-specific range requests. * Persist Microsoft `tenantId` in auth repository to validate work/school availability capabilities. * Add Microsoft shared calendar service and update range coverage filtering * Add `microsoftSharedCalendarServiceProvider` to support Microsoft shared calendar operations. * Update `microsoftCalendarApiClientForAccountProvider` to supply account tenant ID and shared calendar authorization header callback. * Forward account and source filters to `ensureCloudCoverage` within `scheduleRepositoryProvider`. * Add attachment management and shared calendar support on Windows * Add file upload and deletion capabilities for Microsoft event and task attachments on Windows. * Add support for opening Microsoft shared calendars in Windows settings. * Restrict Microsoft attendee availability lookups to work/school accounts and ensure proper event description editing. * Filter schedule cloud coverage checks and enhance loading feedback in the Windows schedule view. * Add localization strings for shared calendars and linked resources across supported languages. * Add run configuration for BusyMax on Linux * Add unit tests for DAV URI attachment patch operations * Add tests for Google Calendar ACL management and event label retrieval * Add tests for primary calendar permissions and master category retrieval * Add tests for category authorization header and consent persistence * Add tests for attachment management, recurring event handling, and Google ACL operations * Add Linux tests for calendar label selection, working location, and Microsoft master category assignment * Add Windows tests for category selection, working locations, and Google label queuing in event editor * Add label and category management features for Microsoft and Google calendars, including event permissions, label properties, and authorization updates. * Add federated recipient support and DAV URI attachment patch implementation * Add support for Microsoft category authorization header in AccountTokenBroker * Add Google primary-calendar status event validation, label management, and sharing service implementation * Add support for Microsoft master category retrieval and Google assignment deletion confirmation * Enhance task handling by introducing robust Google assignment validation across create, update, move, and delete operations. * Add recurring event series export functionality with Google and Microsoft calendar support. * Add cloud calendar sharing management functionality with UI support and account-based source eligibility checks * Add support for Google calendar status event handling and dynamic retry interval for schedule coverage checks * Add Google event label and Microsoft master category providers, including category authorization support * Add Windows cloud calendar sharing dialog and recurring event series export functionality for Google and Microsoft calendars * Add support for Google event labels, Microsoft master categories, and enhanced handling of status events with UI updates and category authorization * Add localized strings for calendar sharing, Google event types, working locations, and chat statuses in supported languages * Enhance documentation for calendar sharing and event management, including ACL grants, federation support, and status event handling across Google, Microsoft, and Nextcloud * Add tests for calendar sync, attachment upload handling, and advanced status/event operations across Google, Microsoft, and DAV contexts. * Add support for Google status type context in calendar event mutations * Validate Google status event types and enhance mutation mapping logic * Validate and handle editable DAV reminder rows in mutation logic * Add attachment upload coordinator provider to app bootstrap * Handle locally removed Microsoft shared calendar owners in repository and service logic * Handle DAV reminder rows in notification scheduling logic * Integrate attachment upload reconciliation and Microsoft series timezone support into event and calendar logic. * Validate and handle deleted calendar sources, enhance all-day event timezone logic, and integrate Google status event type context into calendar mutations. * Convert `_CalendarSettingsRow` to `ConsumerWidget` and add support for removing Microsoft shared calendars. * Integrate attachment upload reconciliation and update task editor to handle upload progress and errors. * Integrate attachment upload reconciliation into task and event dialogs, add support for removing Microsoft shared calendars, and convert `_WindowsCalendarSettingsRow` to `ConsumerWidget`. * Integrate attachment upload reconciliation for tasks and events, add support for editable Nextcloud reminders, and extend deletion logic for Microsoft shared calendars. * Add translations for shared calendar removal confirmation and unresolved attachment upload status. * Add tool to generate POSIX continuation rules from TZif footers in IANA tzdata files * Integrate attachment upload reconciliation tests for tasks and events across platforms, handle unresolved upload states, and enhance timezone logic in calendar series exports. * Add attachment upload session handling for tasks and events, support resuming and canceling uploads, and improve progress tracking and error handling. * Enable session handling and manual resolution for attachment uploads, add support for canceling and resuming uploads, and refine timezone logic in calendar series exports. * Enable manual resolution and session handling for attachment uploads, add cancel and retry options, and improve error handling in task and event dialogs. * Enable manual resolution, cancel, and retry options for attachment uploads in task and event dialogs, and improve session handling. * Add attachment removal support for tasks and events to correctly clear pending upload confirmations and improve reconciliation handling. * Fix disposal handling in AttachmentUploadCoordinator to prevent notifications after disposal and improve async refresh handling. * * Harden cloud calendar sharing to safely handle interrupted network responses, track unresolved mutation outcomes, and provide retry mechanisms across Android, Linux, and Windows. * Refactor Microsoft Graph scope validation to accurately handle short and mixed scope spellings, preventing unnecessary OAuth reconnections and consent prompts. * Restrict edits on Gmail-derived Google events to supported fields (color, reminders, visibility, attendees) and prevent unsupported calendar moves or type changes. * Add mapping for iCalendar classifications (`PUBLIC`, `PRIVATE`, `CONFIDENTIAL`) to Microsoft event sensitivity levels during import, and validate destination support. * Add comprehensive test coverage for calendar sharing state recovery, Gmail event mutation limits, iCalendar sensitivity mapping, and Microsoft OAuth scope parsing. * Treat pre-dispatch request failures as known uncommitted errors in `CloudCalendarSharingService` to prevent sharing operations from incorrectly entering an unresolved state when network requests fail prior to dispatch. * Rename `_isConfirmedRejection` to `_isKnownUncommittedFailure` and handle `RequestNotDispatchedException` as a safe uncommitted error state. * Maintain active grant states and restore UI mutation controls when calendar sharing network requests fail before transmission. * Add unit and widget test coverage for pre-dispatch network failures during calendar grant additions, updates, and revocations across Android, Linux, and Windows. * Harden cloud calendar sharing against authentication pre-dispatch failures and status code ambiguity * Wrap authorization header resolution and token refresh errors in `GoogleCalendarApiClient` and `MicrosoftCalendarApiClient` with `KnownUnsentRequestException`. * Exclude HTTP 408, 409, and 429 status codes from default 4xx pre-dispatch error classification in `CloudCalendarSharingService` to avoid treating ambiguous responses as unsent. * Add unit tests covering pre-dispatch authentication failures, failed 401 token refreshes, and ambiguous HTTP status codes during calendar sharing operations. * Handle rate limiting and cooldowns in cloud calendar sharing * Add `parseHttpRetryAfter` utility to extract retry delays from HTTP headers. * Update Google and Microsoft Calendar API error handlers to parse retry hints and identify rate-limiting status. * Implement cooldown tracking and backoff logic in `CloudCalendarSharingService` to prevent invalid retry mutations during rate limits. * Disable sharing actions and display rate-limit feedback in Android, Linux, and Windows calendar sharing views with automatic re-activation upon cooldown expiration. * Add localized error string for rate-limited calendar sharing across supported locales. * Implement rate limiting for attachment uploads and refine iCalendar import metadata * Add HTTP 429 Retry-After parsing to Microsoft Todo API error handling. * Support rate-limiting cooldowns and delay tracking in `AttachmentUploadCoordinator`. * Display localized rate-limit status messages in event and task attachment views across Android, Linux, and Windows. * Refactor iCalendar import logic to preserve classifications, transparency, categories, and zero-minute alarms across Google and Microsoft providers. * Add unit and widget tests for attachment upload rate-limiting backoff and iCalendar exception metadata replay. * * Resolve civil wall times using draft time zones for cloud availability queries across Android, Linux, and Windows. * Hide the recurrence rule editor for recurring event instances in Android and Windows event dialogs. * Handle Microsoft delta view removals in the calendar sync engine by verifying event existence before marking deleted locally. * Add targeted instance lookup with `originalStart` to `GoogleCalendarApiClient` and pending operation replay logic. * Enforce matching visibility across series masters and occurrences for Google Calendar iCal imports. * Exclude non-`START` relative triggers (e.g., `RELATED=END`) from display alarm projections in DAV and Android parsers. * Fix event label change detection in the Windows event editor dialog. * Fix all-day cloud availability time zones and handle missing Google default reminders * Pass calendar time zone to guest availability dialogs across Android, Linux, and Windows to ensure all-day event intervals resolve accurately using the selected source zone. * Support absent or null `defaultReminders` in Google calendar series exports by defaulting to an empty list instead of throwing a format exception. * Add unit and widget test coverage for all-day availability time zone resolution and Google series export reminder handling. * Fix Nextcloud federated share principal href encoding Avoid URI-encoding base64-encoded identifiers when constructing Nextcloud remote-user principal `href`s, ensuring trailing base64 padding is preserved without percent-encoding. --- .run/BusyMax Linux.run.xml | 7 + docs/google_setup.md | 5 + docs/live_provider_testing.md | 101 + docs/microsoft_setup.md | 12 + docs/nextcloud_setup.md | 9 + docs/provider_support_matrix.md | 104 +- lib/l10n/app_ar.arb | 57 +- lib/l10n/app_de.arb | 57 +- lib/l10n/app_en.arb | 60 +- lib/l10n/app_es.arb | 57 +- lib/l10n/app_et.arb | 57 +- lib/l10n/app_fa.arb | 57 +- lib/l10n/app_fi.arb | 57 +- lib/l10n/app_fr.arb | 57 +- lib/l10n/app_hi.arb | 57 +- lib/l10n/app_it.arb | 57 +- lib/l10n/app_ja.arb | 57 +- lib/l10n/app_ko.arb | 57 +- lib/l10n/app_pl.arb | 57 +- lib/l10n/app_pt_PT.arb | 57 +- lib/l10n/app_ru.arb | 57 +- lib/l10n/app_vi.arb | 57 +- lib/l10n/app_zh.arb | 57 +- lib/l10n/app_zh_Hans.arb | 57 +- lib/l10n/app_zh_Hant.arb | 57 +- lib/l10n/generated/app_localizations.dart | 290 ++- lib/l10n/generated/app_localizations_ar.dart | 159 +- lib/l10n/generated/app_localizations_de.dart | 160 +- lib/l10n/generated/app_localizations_en.dart | 159 +- lib/l10n/generated/app_localizations_es.dart | 160 +- lib/l10n/generated/app_localizations_et.dart | 160 +- lib/l10n/generated/app_localizations_fa.dart | 159 +- lib/l10n/generated/app_localizations_fi.dart | 160 +- lib/l10n/generated/app_localizations_fr.dart | 162 +- lib/l10n/generated/app_localizations_hi.dart | 160 +- lib/l10n/generated/app_localizations_it.dart | 160 +- lib/l10n/generated/app_localizations_ja.dart | 156 +- lib/l10n/generated/app_localizations_ko.dart | 155 +- lib/l10n/generated/app_localizations_pl.dart | 160 +- lib/l10n/generated/app_localizations_pt.dart | 318 ++- lib/l10n/generated/app_localizations_ru.dart | 161 +- lib/l10n/generated/app_localizations_vi.dart | 160 +- lib/l10n/generated/app_localizations_zh.dart | 450 +++- lib/src/android/android_authorization.dart | 190 +- .../android_availability_dialog.dart | 53 +- ...ndroid_cloud_calendar_sharing_content.dart | 383 +++ .../android_event_attachments_dialog.dart | 537 ++++ .../android_recurrence_dialog.dart | 290 +++ .../presentation/android_schedule_screen.dart | 1559 ++++++++++- .../presentation/android_settings_screen.dart | 132 +- .../presentation/android_tasks_screen.dart | 572 ++++- lib/src/app/app_bootstrap.dart | 184 +- .../attachment_upload_session.dart | 81 + .../calendar_description.dart | 175 ++ .../calendar_providers/calendar_mutation.dart | 13 + .../calendar_provider_capabilities.dart | 19 + .../cloud_calendar_client.dart | 16 + lib/src/core/auth/account_token_broker.dart | 12 + lib/src/core/auth/microsoft_graph_scopes.dart | 24 + lib/src/core/http/retry_after.dart | 19 + .../core/time/stored_temporal_projection.dart | 29 + .../mutation/dav_projection_mutations.dart | 142 +- .../nextcloud/nextcloud_sharing_service.dart | 58 +- .../features/auth/data/auth_repository.dart | 19 +- .../calendar/data/calendar_repository.dart | 500 +++- .../data/cloud_calendar_sharing_service.dart | 499 ++++ .../microsoft_shared_calendar_service.dart | 70 + .../calendar/domain/google_status_event.dart | 115 + .../cloud_calendar_sharing_content.dart | 381 +++ .../cloud_calendar_sharing_labels.dart | 12 + .../event_description_editor.dart | 20 +- .../calendar/presentation/event_editor.dart | 403 ++- .../presentation/event_editor_draft.dart | 218 +- .../google_status_event_labels.dart | 65 + .../linux_cloud_availability_dialog.dart | 141 + .../notification_schedule_service.dart | 19 + .../presentation/attachment_download.dart | 38 + .../attachment_upload_coordinator.dart | 510 ++++ .../cloud_calendar_series_export.dart | 1003 ++++++++ .../google_event_attachment_reference.dart | 58 + .../linux_event_attachments_dialog.dart | 503 ++++ .../schedule_event_details_format.dart | 150 ++ .../schedule_item_details_popover.dart | 73 +- .../presentation/schedule_workspace.dart | 142 +- .../presentation/tz_continuations_2025c.dart | 604 +++++ .../presentation/settings_screen.dart | 125 +- .../sync/calendar_pending_ops_replayer.dart | 350 ++- .../features/sync/calendar_sync_engine.dart | 210 ++ ...cloud_calendar_range_coverage_service.dart | 134 + .../features/sync/pending_ops_replayer.dart | 145 +- .../features/tasks/data/tasks_repository.dart | 74 +- .../domain/google_task_assignment_policy.dart | 72 + .../tasks/domain/task_source_links.dart | 61 + .../tasks/presentation/new_task_dialog.dart | 1 + .../presentation/task_details_editor.dart | 609 ++++- .../tasks/presentation/task_details_pane.dart | 7 +- .../google_calendar_api_client.dart | 323 ++- .../google_calendar_errors.dart | 26 + .../google_calendar_mapper.dart | 29 +- .../google_calendar_models.dart | 62 + lib/src/ical/ical_event_projection.dart | 21 + lib/src/ical/ical_import_service.dart | 260 +- .../microsoft_calendar_api_client.dart | 752 +++++- .../microsoft_calendar_errors.dart | 7 + .../microsoft_calendar_mapper.dart | 19 +- .../microsoft_calendar_models.dart | 126 +- .../microsoft_event_attachment.dart | 43 + .../microsoft_shared_calendar_address.dart | 41 + .../api/microsoft_todo_api_client.dart | 296 ++- .../api/microsoft_todo_api_error.dart | 10 + .../api/microsoft_todo_api_models.dart | 104 + .../api/microsoft_todo_paths.dart | 15 + .../oauth/microsoft_oauth_service.dart | 239 +- lib/src/schedule/event_attachment_link.dart | 49 + lib/src/schedule/schedule_item.dart | 32 + lib/src/schedule/schedule_repository.dart | 243 +- .../windows_cloud_availability_dialog.dart | 142 + ...windows_cloud_calendar_sharing_dialog.dart | 396 +++ .../windows_event_attachments_dialog.dart | 570 +++++ .../windows/windows_event_editor_dialog.dart | 661 ++++- lib/src/ui/windows/windows_schedule_page.dart | 310 ++- lib/src/ui/windows/windows_settings_page.dart | 143 +- .../windows/windows_task_details_dialog.dart | 650 ++++- .../calendar_description_test.dart | 104 + .../dav/mutation/dav_mutation_patch_test.dart | 78 + ..._repository_mutation_integration_test.dart | 54 + .../dav_uri_attachment_patch_test.dart | 108 + .../nextcloud/nextcloud_admin_fixture.dart | 3 + .../nextcloud_administration_test.dart | 58 + .../dav/nextcloud/nextcloud_dialogs_test.dart | 103 + .../data/calendar_repository_test.dart | 409 +++ .../cloud_calendar_sharing_service_test.dart | 2279 +++++++++++++++++ .../domain/google_status_event_test.dart | 104 + .../presentation/event_editor_test.dart | 441 ++++ .../notification_schedule_service_test.dart | 52 + .../schedule/attachment_download_test.dart | 24 + .../schedule/event_attachment_link_test.dart | 22 + .../cloud_calendar_series_export_test.dart | 985 +++++++ .../schedule_search_titlebar_test.dart | 6 + .../presentation/schedule_views_test.dart | 166 ++ .../schedule_workspace_navigation_test.dart | 9 + .../schedule/schedule_search_test.dart | 147 ++ .../presentation/settings_screen_test.dart | 58 + .../sync/calendar_event_clear_patch_test.dart | 193 +- .../calendar_pending_ops_replayer_test.dart | 832 +++++- .../sync/calendar_sync_engine_test.dart | 899 ++++++- .../sync/pending_ops_replayer_test.dart | 296 ++- .../tasks/data/tasks_repository_test.dart | 102 + .../tasks/domain/task_source_links_test.dart | 29 + .../presentation/task_details_pane_test.dart | 603 ++++- .../google_calendar_api_client_test.dart | 541 ++++ .../google_calendar_mapper_test.dart | 93 + test/ical/ical_import_service_test.dart | 1334 +++++++++- test/ical/ical_ingestion_test.dart | 72 + .../microsoft_calendar_api_client_test.dart | 763 ++++++ .../microsoft_calendar_mapper_test.dart | 43 +- ...osoft_shared_calendar_projection_test.dart | 88 + .../api/microsoft_todo_api_client_test.dart | 583 +++++ .../api/microsoft_todo_api_models_test.dart | 66 + ...t_todo_linked_resources_provider_test.dart | 125 + .../oauth/microsoft_oauth_service_test.dart | 418 +++ .../android/android_adaptive_app_test.dart | 1672 ++++++++++-- .../android_authorization_broker_test.dart | 34 + .../android_event_attendee_edit_test.dart | 214 ++ .../android_recurrence_dialog_test.dart | 142 + .../native_event_attachments_test.dart | 2151 ++++++++++++++++ ...ws_cloud_calendar_sharing_dialog_test.dart | 579 +++++ .../windows_settings_startup_test.dart | 82 +- .../windows_task_details_dialog_test.dart | 6 +- .../windows_event_preservation_test.dart | 671 +++++ .../windows_schedule_commands_test.dart | 14 +- .../windows_task_editor_safety_test.dart | 179 +- tool/generate_tz_continuations.dart | 58 + 173 files changed, 39438 insertions(+), 815 deletions(-) create mode 100644 .run/BusyMax Linux.run.xml create mode 100644 lib/src/android/presentation/android_cloud_calendar_sharing_content.dart create mode 100644 lib/src/android/presentation/android_event_attachments_dialog.dart create mode 100644 lib/src/android/presentation/android_recurrence_dialog.dart create mode 100644 lib/src/calendar_providers/attachment_upload_session.dart create mode 100644 lib/src/core/auth/microsoft_graph_scopes.dart create mode 100644 lib/src/core/http/retry_after.dart create mode 100644 lib/src/features/calendar/data/cloud_calendar_sharing_service.dart create mode 100644 lib/src/features/calendar/data/microsoft_shared_calendar_service.dart create mode 100644 lib/src/features/calendar/domain/google_status_event.dart create mode 100644 lib/src/features/calendar/presentation/cloud_calendar_sharing_content.dart create mode 100644 lib/src/features/calendar/presentation/cloud_calendar_sharing_labels.dart create mode 100644 lib/src/features/calendar/presentation/google_status_event_labels.dart create mode 100644 lib/src/features/calendar/presentation/linux_cloud_availability_dialog.dart create mode 100644 lib/src/features/schedule/presentation/attachment_download.dart create mode 100644 lib/src/features/schedule/presentation/attachment_upload_coordinator.dart create mode 100644 lib/src/features/schedule/presentation/cloud_calendar_series_export.dart create mode 100644 lib/src/features/schedule/presentation/google_event_attachment_reference.dart create mode 100644 lib/src/features/schedule/presentation/linux_event_attachments_dialog.dart create mode 100644 lib/src/features/schedule/presentation/schedule_event_details_format.dart create mode 100644 lib/src/features/schedule/presentation/tz_continuations_2025c.dart create mode 100644 lib/src/features/sync/cloud_calendar_range_coverage_service.dart create mode 100644 lib/src/features/tasks/domain/google_task_assignment_policy.dart create mode 100644 lib/src/features/tasks/domain/task_source_links.dart create mode 100644 lib/src/microsoft_calendar/microsoft_event_attachment.dart create mode 100644 lib/src/microsoft_calendar/microsoft_shared_calendar_address.dart create mode 100644 lib/src/schedule/event_attachment_link.dart create mode 100644 lib/src/ui/windows/windows_cloud_availability_dialog.dart create mode 100644 lib/src/ui/windows/windows_cloud_calendar_sharing_dialog.dart create mode 100644 lib/src/ui/windows/windows_event_attachments_dialog.dart create mode 100644 test/dav/mutation/dav_uri_attachment_patch_test.dart create mode 100644 test/features/calendar/data/cloud_calendar_sharing_service_test.dart create mode 100644 test/features/calendar/domain/google_status_event_test.dart create mode 100644 test/features/schedule/attachment_download_test.dart create mode 100644 test/features/schedule/event_attachment_link_test.dart create mode 100644 test/features/schedule/presentation/cloud_calendar_series_export_test.dart create mode 100644 test/features/tasks/domain/task_source_links_test.dart create mode 100644 test/microsoft_calendar/microsoft_shared_calendar_projection_test.dart create mode 100644 test/microsoft_todo/api/microsoft_todo_linked_resources_provider_test.dart create mode 100644 test/platform/android/android_recurrence_dialog_test.dart create mode 100644 test/platform/native_event_attachments_test.dart create mode 100644 test/platform/windows/windows_cloud_calendar_sharing_dialog_test.dart create mode 100644 tool/generate_tz_continuations.dart diff --git a/.run/BusyMax Linux.run.xml b/.run/BusyMax Linux.run.xml new file mode 100644 index 00000000..a7da6049 --- /dev/null +++ b/.run/BusyMax Linux.run.xml @@ -0,0 +1,7 @@ + + + + diff --git a/docs/google_setup.md b/docs/google_setup.md index 428deffe..45a39a41 100644 --- a/docs/google_setup.md +++ b/docs/google_setup.md @@ -27,6 +27,11 @@ the console as `https://www.googleapis.com/auth/userinfo.email` and provide the account identity and display label; the Tasks and Calendar scopes authorize provider data access. +Calendar-owner accounts can manage individual user ACL grants from BusyMax +settings using the existing Calendar read/write scope. This does not grant +domain administration, public publishing, or ownership transfer. The server's +actual owner access role and ACL response determine available actions. + Google documents the current console areas in [Get started with Google Auth Platform](https://support.google.com/cloud/answer/15544987) and the Tasks permission in diff --git a/docs/live_provider_testing.md b/docs/live_provider_testing.md index 6a0f10e3..714a9423 100644 --- a/docs/live_provider_testing.md +++ b/docs/live_provider_testing.md @@ -134,6 +134,96 @@ iCloud.com. ## Coverage that still needs real systems +For the bounded cloud-range and native-detail changes, use disposable Google +and Microsoft calendars and record the provider application, BusyMax build, +OS, and test date. Create one event before and one after the initial sync +horizon, navigate/search those months, change and delete events in the +provider, then reopen offline and after reconnect. Check that cached results +are marked incomplete offline and that baseline incremental synchronization +continues after the range reads. Use a read-only invitation with a real +provider-supplied meeting URL to check details and browser launch on each +native platform; separately check an invalid URL and a general Nextcloud event +URL. For Microsoft, edit an HTML event that contains a Teams meeting, verify +the changed body and meeting in Outlook after replay, then restart and check +notification reconciliation. For a Google Docs/Chat-assigned task, cancel the +deletion warning and verify both the original and queue remain unchanged; +then explicitly confirm on a separate disposable task and verify deletion in +the assignment surface. These are manual acceptance cases until run and +recorded against the actual accounts; a passing widget test is not a live +provider result. + +For guest availability, check Google and a Microsoft work/school account from +Linux, Windows, and Android with one free recipient, one busy recipient, one +denied recipient, and one nonexistent recipient. Confirm the denied/missing +rows remain unknown, including after refresh, account changes, and a +daylight-saving boundary. A personal Microsoft account must not be reported as +free or be sent to Graph `getSchedule`. Verify opening availability neither +saves the draft nor sends invitations. Retest a Nextcloud collection whose +effective `canQueryFreeBusy` permission is denied. + +For Android event authoring, open a recurring event with an every-two-weeks +weekday rule and multiple existing reminders. Save an unrelated title edit and +verify the original recurrence/reminders in the provider application. Then +edit interval, weekdays, and termination; separately test provider default, +no reminder, and a reminder at start. Change one guest between required and +optional without changing existing responses. Create a Meet/Teams conference +only on a calendar that advertises its solution, and confirm that the provider +created the meeting rather than BusyMax merely showing a local switch. + +For iCalendar export, choose both the standalone occurrence and entire-series +options on each native platform. Use a disposable Google and Microsoft series +with a moved exception, a cancelled occurrence, an all-day case, and a rule +crossing a daylight-saving transition. Open the resulting file in a separate +calendar application and inspect UID, original recurrence identities, local +wall time, alarms, attendees, and any omitted provider-only metadata. Repeat +with a failed later provider page and offline cache: neither may be reported +as a complete series export. Import the same fixture into a disposable +calendar, verify every remote master/exception independently, and confirm no +unintended invitations were delivered. + +On disposable Google calendars, create and edit each native status event type +(focus time, out of office, and working location) on an eligible primary +calendar; verify type-specific properties, visibility/free-busy state, and the +provider's own display after replay. Repeat against an ineligible account and +nonprimary calendar and confirm rejection without an ordinary-event fallback. +Confirm a title-only edit retains the status settings and unsafe recurring +series splitting remains unavailable. On a labeled Google calendar, select and +clear one returned event label, then edit only the title and verify label +preservation; repeat on a different calendar to check ID isolation. On an +Outlook mailbox with multiple existing master categories, select two on an +event and task, leave one unknown/deleted assignment untouched, and verify +the names and colors in Outlook/To Do. Decline optional category consent and +confirm normal editing still works. Record the actual account eligibility, +tenant consent, and provider application results; local tests are not proof. + +For task-source navigation, open a Docs- or Chat-assigned Google task and a +Google task with related links and `webViewLink`, including after offline +reopening. For Microsoft To Do, open a task with multiple linked resources, +explicitly request them in each native task detail, and verify the supplied +application/display names and destination URLs. Repeat with a missing URL, a +denied resource request, and an account switch; editing and completion must +remain usable throughout. Do not treat browser launch or resource access as +successful merely because a URL is displayed. + +For attachment read/access, check a Google event with multiple supplied links, +a Nextcloud event with URI `ATTACH`, and Microsoft events and To Do tasks with +and without attachments. Verify that the Microsoft list is requested only on +explicit opening, that a successful empty list differs from a denied request, +and that downloaded file bytes match the provider copy. Test a reference +attachment separately from a file attachment, an unsafe URL, a malformed +filename, pagination failure, read-only event, and offline cached metadata. +On disposable data, add and remove a Google event attachment reference using +an existing Drive file URL; check the provider event retains other references +and the underlying file still exists. Repeat after an uncertain mutation +response, and verify a normal title edit did not replace a newer attachment +array. Add and remove a Nextcloud URI `ATTACH`; inspect the raw iCalendar +resource for preserved binary attachments, parameters, alarms, exceptions, +and unrelated properties. Check the pending DAV queue, offline reopening, +reconnect, and read-only denial. For Microsoft, separately exercise event and +task file add/remove, small and upload-session paths, explicit download, and +remote state after an uncertain upload outcome. Record each provider mutation +not actually run as **not run**; local widget/API tests are not live acceptance. + Deterministic suites under `test/dav/` cover parsing and preservation, discovery, exact ETags, mutation queues, conflicts, delegated discovery failures, scheduling intent, import/export, and trash/sharing safety with fake @@ -151,6 +241,17 @@ Before a release claim, exercise and record: - collection creation/settings, mixed-collection deletion warnings, self-unshare, publishing, failed refresh after a committed change, and uncertain-outcome reconciliation; +- calendar federation initiation from each native sharing entry point with a + disposable remote ID on an enabled Nextcloud 32+ server; verify recipient + appearance in Nextcloud Calendar, read-only behavior on version 32, and + permitted remote writes only on version 33+; repeat against a server with + federation disabled and confirm no local-only success; +- Google owner-calendar and Microsoft signed-in primary-calendar sharing from + Linux, Windows, and Android settings: list the provider's grants, add a + disposable recipient with an explicit role, change only a provider-allowed + role, then revoke; confirm each state in Google Calendar or Outlook and + verify immutable owner grants, recipient/revoked access, and committed + mutation followed by refresh failure are presented accurately; - organizer invitations, updates, guest removal and cancellation; attendee occurrence/series replies; free/busy failures; inbox acknowledgement; and the actual count of delivered QA messages; diff --git a/docs/microsoft_setup.md b/docs/microsoft_setup.md index 47b63919..c8515191 100644 --- a/docs/microsoft_setup.md +++ b/docs/microsoft_setup.md @@ -45,6 +45,18 @@ refresh tokens. See the official and [OpenID Connect scope reference](https://learn.microsoft.com/en-us/entra/identity-platform/scopes-oidc). +The existing `Calendars.ReadWrite` grant is also used for permission +management on the signed-in account's primary calendar. Outlook determines +each grant's allowed roles and removability; delegated owner-calendar access +is a separate contextual grant and is not a general sharing-administration +permission. + +Selecting from the account's existing Outlook master categories is optional. +BusyMax asks for the delegated `MailboxSettings.Read` permission when that +lookup is requested. Denied or unavailable category metadata does not prevent +editing ordinary calendar events or To Do tasks, and BusyMax does not create +or administer categories. + Supply the application ID as `MICROSOFT_OAUTH_CLIENT_ID`. It is embedded in the desktop package, so treat it as public application configuration and do not commit private build configuration. Do not create or embed a client secret: diff --git a/docs/nextcloud_setup.md b/docs/nextcloud_setup.md index c6d1f1e6..ad4b1c79 100644 --- a/docs/nextcloud_setup.md +++ b/docs/nextcloud_setup.md @@ -58,6 +58,15 @@ Collection settings expose sharing, publishing, and removal controls when Nextcloud advertises them and the account has permission. Write access to event or task content does not automatically grant sharing administration. +For a server that supports calendar federation, enter a remote calendar +recipient as `@` in the existing sharing search. If no +local user or group matches, BusyMax offers the federated recipient and sends +the share request to your own Nextcloud server. Nextcloud 32 supports +read-only federated calendar shares; remote write access requires Nextcloud 33 +or later, and the server administrator can disable federation. A failed or +unsupported share is not saved locally as a successful grant. Verify the +recipient and role in Nextcloud Calendar after sharing. + For supported calendars, organizers can manage guests and attendees can respond from event details. Recurring responses require the offered occurrence-or-series choice. BusyMax also exposes guest availability and the diff --git a/docs/provider_support_matrix.md b/docs/provider_support_matrix.md index 526e206c..1e5f2b3b 100644 --- a/docs/provider_support_matrix.md +++ b/docs/provider_support_matrix.md @@ -29,18 +29,78 @@ fixtures documented for live tests are test-only. | All-day and timed events | Yes | Yes | Yes | Yes | Preserved for display | | Recurrence and reminders | Yes | Yes | Preserved and editable subset | Preserved and editable subset | Read-only | | Attendee editing | Yes | Yes | No | Permission- and scheduling-dependent | No | -| Free/busy workflow | Yes | No | No | Permission- and scheduling-dependent | No | -| Attachments | Supported subset | No | Preserved when present | Preserved when present | Read-only | +| Free/busy workflow | Yes | Work/school accounts only | No | Permission- and scheduling-dependent | No | +| Attachments | Supplied links open; permission-gated event reference add/remove using an existing HTTPS file URL | Event/task lists, explicit file downloads, and permission-gated file addition/removal | Preserved when present | Supplied URI references open; permission-gated URI `ATTACH` add/remove through the DAV queue | Read-only | +| Provider-native event organization | Calendar-scoped event labels can be selected or cleared; ordinary event colors remain separate | Existing mailbox categories can be selected by name and viewed with their account colors; multiple assignments remain intact | Existing fields preserved | Existing categories preserved | Read-only | +| Status events | Primary-calendar focus time, out-of-office, and working location, subject to Google's account eligibility and provider validation | No special authoring | No special authoring | No special authoring | Read-only | 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). +Google and Microsoft start with a bounded synchronization window. Navigating +to another month or using a date-bounded search retrieves that period on +demand when online; a failed or offline retrieval retains cached events and +marks the range incomplete. Unrestricted **Any date** search searches only +downloaded data. The additional range snapshots do not replace the baseline +incremental-sync cursor. + +Individual export distinguishes a standalone occurrence from an entire +recurring series. Google and Microsoft series export reads the provider's +master and exception identities online before writing an iCalendar file; it +does not call a finite local month cache a complete backup. Nextcloud and +iCloud resource export keeps the effective original iCalendar document, +including pending local changes. A standalone occurrence export remains a +portable snapshot, not a series backup. Provider fields that iCalendar cannot +represent are not silently treated as a complete provider-account backup. +WebCal and cloud events still awaiting a remote identity offer occurrence +export only; they do not claim an authoritative series export. + +Native event details show the full interval, available description and guest +information, and provider-supplied meeting links even on read-only calendars. +Nextcloud's ordinary event URL is shown as an event link; only a preserved +conference property is presented as a meeting link. Link opening still depends +on the system browser and on the provider returning a valid URL. + +Google event-label IDs belong to a single calendar. BusyMax requests label +metadata for the selected calendar and uses Google's label-version request +semantics only for explicit label mutations. Unknown labels and Microsoft +category names remain on events when optional metadata lookup is unavailable. +Microsoft's master-category lookup requests `MailboxSettings.Read` separately; +declining that grant does not block ordinary event or task editing. Google's +status-event controls are limited to eligible primary calendars, retain +type-specific properties, and do not permit changing an existing event's type. +BusyMax does not infer Workspace eligibility from an email address; Google's +rejection is reported instead of converting a status event to an ordinary one. + +Attachment references in Google and Nextcloud event details open without +passing provider credentials to those URLs. Microsoft event and task attachment +metadata is fetched only when requested; a `hasAttachments` flag does not mean +an empty list. Microsoft file downloads require an explicit native save action. +Eligible Microsoft event and task details also offer file addition/removal; +an upload error with an uncertain remote outcome requires a refresh before +retrying. Google reference changes read the event's authoritative attachment +array and use its ETag; they never delete the underlying Drive file. Nextcloud +URI-reference changes preserve unrelated iCalendar properties, parameters, +binary attachments, and pending DAV mutations. Neither workflow provides file +browsing or upload to Drive or Nextcloud Files. + +Android's event editor retains untouched recurrence and reminder data. Its +native recurrence dialog edits the shared supported interval, weekday, and +ending subset, while unsupported rules remain unchanged until explicitly +replaced. The reminder control distinguishes provider default, no reminder, +and a zero-minute reminder; multiple existing reminders remain visible and +unchanged until replaced. Guest rows allow required/optional roles, and Meet +or Teams creation is offered only when the selected calendar advertises that +conference solution. + 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 +Schedule detail sheet. Google and eligible Microsoft guest availability is +available in the event editor on all three native platforms after at least one +guest and a valid interval are present. Microsoft uses Graph `getSchedule`; +personal Microsoft accounts are unsupported, and recipient-specific failures +remain unknown rather than appearing free. Capability-enabled Nextcloud guest +availability retains its effective `canQueryFreeBusy` gate. A capability-enabled Nextcloud calendar exposes its scheduling inbox from **Settings > calendar > Scheduling inbox**. @@ -59,10 +119,17 @@ Scheduling inbox**. | Status/progress/completed time | Completion only | Provider completion state | iCalendar status, percentage, and completion time | | Location, URL, classification | No | No | Yes | | Advanced iCalendar fields | No | No | Priority, alarms, recurrence, pinning, and subtask visibility | - -Google exposes assigned and hidden tasks from its API, but provider rules can -limit which of those tasks BusyMax may change. Nextcloud recurrence rules or -alarm forms outside BusyMax's editable subset remain preserved and read-only; +| Task source links | Supplied assignment/related/task-page URLs | Task-scoped linked resources fetched on request | Preserved task URL where present | + +Google exposes assigned and hidden tasks from its API. Docs-assigned tasks +cannot receive notes, and assigned tasks cannot become subtasks or parents; +other permitted edits and top-level ordering remain available. Deletion of an +assigned task requires a warning because it also deletes the original in Docs +or Chat Spaces. Google source links remain available from cached task metadata +offline. Microsoft linked resources require an online Graph detail request and +may be unavailable without affecting task edits or completion. Nextcloud +recurrence rules or alarm forms outside BusyMax's editable subset remain +preserved and read-only; detached task occurrences are not edited directly. ## Collection administration @@ -75,7 +142,7 @@ Object editing and collection administration are separate capabilities. | Rename/recolor/delete calendar | Yes | Yes | No | Online, property/owner permission-dependent | No | | Create task list | Yes | Yes | No | Online, with permission | No | | Rename/delete task list | Yes | Yes | No | Permission-dependent | No | -| Share or publish collections | No | No | No | Advertised, permission-dependent | No | +| Share or publish collections | Owner-calendar ACL list/add/role/revoke from native settings; no public/domain administration | Signed-in primary-calendar permission list/add/role/revoke from native settings; provider allowed-role/removability enforced | No | Advertised, permission-dependent; calendar federation initiation on supported servers | No | | Restore deleted objects/collections | No | No | No | Advertised, permission-dependent | No | | Native collection import/export | No | No | No | Supported subset | No | @@ -85,6 +152,21 @@ sharing, publishing, trash, and native import/export behavior is described in [Nextcloud setup](nextcloud_setup.md) and the [technical reference](icalendar_data_model.md). +Google and Microsoft sharing administration is available from Linux, Windows, +and Android settings for eligible calendars. Google requires an owner access +role; Microsoft management targets the signed-in account's primary calendar, +not every recipient-local or delegated calendar. Provider denials and revoked +access remain errors rather than local-only changes. A successful grant with a +failed follow-up refresh is shown as committed but requires a fresh list before +another change. Public publishing, ownership transfer, and domain-wide +administration are not included. + +Nextcloud calendar sharing accepts local users/groups and a validated +federated ID when the installed server supports calendar federation. BusyMax +sends the share only through the authenticated local DAV server. Nextcloud 32 +provides read-only federation; writable federated shares require Nextcloud 33 +or later. + ## Offline and verification boundaries All providers cache calendar/task data locally for viewing. Queued offline diff --git a/lib/l10n/app_ar.arb b/lib/l10n/app_ar.arb index 208a5bb6..6dcf5303 100644 --- a/lib/l10n/app_ar.arb +++ b/lib/l10n/app_ar.arb @@ -171,7 +171,11 @@ }, "newEvent": "حدث جديد", "refreshCalendar": "تحديث التقويم", - "openInProvider": "فتح في الخدمة", +"openInProvider": "فتح في الخدمة", + "linkedResources": "الموارد المرتبطة", + "noLinkedResources": "لا توجد موارد مرتبطة", + "attachments": "المرفقات", + "attachmentsNotLoaded": "لم يتم تحميل المرفقات", "hideFromSchedule": "إخفاء من الجدول", "showInSchedule": "إظهار في الجدول", "noCalendarsSynced": "لم تتم مزامنة أي تقويمات بعد.", @@ -467,7 +471,8 @@ "moveToTop": "نقل إلى الأعلى", "deleteTask": "حذف المهمة", "newSubtask": "مهمة فرعية جديدة", - "deleteTaskConfirmation": "حذف «⁨⁨{title}⁩⁩»؟", + "deleteTaskConfirmation": "حذف «⁨⁨{title}⁩⁩»؟", + "deleteAssignedTaskWarning": "سيؤدي هذا أيضًا إلى حذف المهمة الأصلية في مستندات Google أو مساحات Chat.", "metadata": "البيانات الوصفية", "id": "المعرّف", "etag": "ETag", @@ -826,6 +831,8 @@ "removeFromMyCalendars": "إزالة من تقاويمي", "removeAction": "إزالة", "removeCalendarConfirmation": "هل تريد إزالة \"⁨{title}⁩\" من قائمة تقويم Google؟ لن يتم حذف التقويم المشترك أو أحداثه.", + "removeOpenedSharedCalendarConfirmation": "هل تريد إزالة \"⁨{title}⁩\" من BusyMax؟ لن يُحذف تقويم المالك أو أحداثه.", + "attachmentUploadUnresolved": "ربما اكتمل رفع المرفق. حدّث المرفقات للتحقق من النتيجة قبل الرفع مرة أخرى.", "calendarCannotRemove": "لا يمكن حذف هذا التقويم أو إزالته من هذا الحساب.", "calendarPendingChangesPreventRemoval": "انتظر حتى تنتهي مزامنة التغييرات المعلقة لهذا التقويم قبل حذفه أو إزالته.", "taskListPendingChangesPreventRemoval": "زامِن الحساب، أو عالِج التغييرات المحظورة لقائمة المهام هذه في «التشخيصات»، قبل حذف القائمة أو إزالتها.", @@ -1036,7 +1043,10 @@ "searchFilters": "عوامل تصفية البحث", "searchType": "النوع", "searchDate": "التاريخ", - "searchAnyDate": "أي تاريخ", + "searchAnyDate": "أي تاريخ (البيانات المحمّلة)", + "eventLink": "رابط الحدث", + "eventLinkOpenFailed": "تعذّر فتح الرابط.", + "scheduleRangeIncomplete": "تعذّر التحقق من بعض الأحداث. تُعرض البيانات المحمّلة.", "searchThisWeek": "هذا الأسبوع", "searchCustomRange": "نطاق مخصص", "searchTaskStatus": "حالة المهمة", @@ -1051,5 +1061,44 @@ "searchClearText": "مسح النص", "windowMinimize": "تصغير", "windowMaximize": "تكبير", - "windowRestore": "استعادة" + "windowRestore": "استعادة", + "openSharedCalendar": "فتح تقويم مشترك", + "calendarOwnerEmail": "البريد الإلكتروني لمالك التقويم", + "addCalendarShare": "إضافة وصول", + "calendarShareFreeBusy": "التوفر فقط", + "calendarShareLimitedRead": "قراءة تفاصيل محدودة", + "calendarShareOwner": "المالك", + "calendarShareRead": "قراءة جميع التفاصيل", + "calendarShareWrite": "تعديل الأحداث", + "calendarShareWriteWithoutPrivate": "تعديل دون التفاصيل الخاصة", + "eventLabel": "تسمية الحدث", + "googleChatAvailable": "متاح", + "googleChatDoNotDisturb": "عدم الإزعاج", + "googleChatStatus": "حالة المحادثة", + "googleDeclineAll": "رفض جميع الدعوات المتعارضة", + "googleDeclineInvitations": "رفض الدعوات المتداخلة", + "googleDeclineMessage": "رسالة الرفض", + "googleDeclineNew": "رفض الدعوات الجديدة", + "googleDeclineNone": "عدم الرفض", + "googleEventType": "نوع الحدث", + "googleFocusTime": "وقت التركيز", + "googleOutOfOffice": "خارج المكتب", + "googleRegularEvent": "حدث عادي", + "googleStatusPrimaryOnly": "تتطلب أحداث الحالة تقويم Google الأساسي.", + "googleWorkAtCustomLocation": "موقع مخصص", + "googleWorkAtHome": "المنزل", + "googleWorkAtOffice": "المكتب", + "googleWorkLocationLabel": "وصف الموقع", + "googleWorkingLocation": "موقع العمل", + "loadOutlookCategories": "تحميل فئات Outlook", + "manageCalendarSharing": "إدارة مشاركة التقويم", + "outlookCategoriesUnavailable": "فئات Outlook غير متاحة؛ التعيينات الحالية محفوظة.", + "shareRecipientEmail": "البريد الإلكتروني للمستلم", + "shareRole": "دور الوصول", + "sharingPermissionUnavailable": "أذونات المشاركة غير متاحة لهذا التقويم.", + "sharingRefreshFailed": "نجح تغيير المشاركة، لكن تعذر تحديث قائمة الأذونات. أعد تحميل القائمة قبل إجراء تغيير آخر.", + "sharingRateLimited": "المشاركة مقيّدة مؤقتًا. حاول مرة أخرى بعد انتهاء فترة الانتظار.", + "attachmentUploadRateLimited": "تحميل المرفقات مقيّد مؤقتًا. حاول مرة أخرى بعد انتهاء فترة الانتظار.", + "unknownEventLabel": "تسمية غير معروفة (⁨{id}⁩)", + "@unknownEventLabel": {"placeholders": {"id": {"type": "String"}}} } diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 85005dff..fa3d43b0 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -171,7 +171,11 @@ }, "newEvent": "Neuer Termin", "refreshCalendar": "Kalender aktualisieren", - "openInProvider": "Beim Anbieter öffnen", +"openInProvider": "Beim Anbieter öffnen", + "linkedResources": "Verknüpfte Ressourcen", + "noLinkedResources": "Keine verknüpften Ressourcen", + "attachments": "Anhänge", + "attachmentsNotLoaded": "Anhänge nicht geladen", "hideFromSchedule": "Im Zeitplan ausblenden", "showInSchedule": "Im Zeitplan anzeigen", "noCalendarsSynced": "Noch keine Kalender synchronisiert.", @@ -509,7 +513,8 @@ "moveToTop": "Ganz nach oben verschieben", "deleteTask": "Aufgabe löschen", "newSubtask": "Neue Unteraufgabe", - "deleteTaskConfirmation": "\"{title}\" löschen?", + "deleteTaskConfirmation": "\"{title}\" löschen?", + "deleteAssignedTaskWarning": "Dadurch wird auch die ursprüngliche Aufgabe in Google Docs oder Chat-Bereichen gelöscht.", "metadata": "Metadaten", "id": "ID", "etag": "ETag", @@ -889,6 +894,8 @@ "removeFromMyCalendars": "Aus meinen Kalendern entfernen", "removeAction": "Entfernen", "removeCalendarConfirmation": "„{title}“ aus Ihrer Google-Kalenderliste entfernen? Der freigegebene Kalender und seine Termine werden nicht gelöscht.", + "removeOpenedSharedCalendarConfirmation": "„{title}“ aus BusyMax entfernen? Der Kalender und die Termine des Besitzers werden nicht gelöscht.", + "attachmentUploadUnresolved": "Der Upload wurde möglicherweise abgeschlossen. Aktualisieren Sie die Anhänge, bevor Sie erneut hochladen.", "calendarCannotRemove": "Dieser Kalender kann nicht gelöscht oder aus diesem Konto entfernt werden.", "calendarPendingChangesPreventRemoval": "Warten Sie, bis die ausstehenden Änderungen dieses Kalenders synchronisiert wurden, bevor Sie ihn löschen oder entfernen.", "taskListPendingChangesPreventRemoval": "Synchronisieren Sie das Konto oder beheben Sie die blockierten Änderungen dieser Aufgabenliste unter „Diagnose“, bevor Sie die Liste löschen oder entfernen.", @@ -1099,7 +1106,10 @@ "searchFilters": "Suchfilter", "searchType": "Eintragstyp", "searchDate": "Datum", - "searchAnyDate": "Beliebiges Datum", + "searchAnyDate": "Beliebiges Datum (heruntergeladene Daten)", + "eventLink": "Terminlink", + "eventLinkOpenFailed": "Der Link konnte nicht geöffnet werden.", + "scheduleRangeIncomplete": "Einige Termine konnten nicht geprüft werden. Heruntergeladene Daten werden angezeigt.", "searchThisWeek": "Diese Woche", "searchCustomRange": "Eigener Zeitraum", "searchTaskStatus": "Aufgabenstatus", @@ -1114,5 +1124,44 @@ "searchClearText": "Text löschen", "windowMinimize": "Minimieren", "windowMaximize": "Maximieren", - "windowRestore": "Wiederherstellen" + "windowRestore": "Wiederherstellen", + "openSharedCalendar": "Freigegebenen Kalender öffnen", + "calendarOwnerEmail": "E-Mail-Adresse des Kalenderbesitzers", + "addCalendarShare": "Zugriff hinzufügen", + "calendarShareFreeBusy": "Nur Frei/Gebucht", + "calendarShareLimitedRead": "Eingeschränkte Details lesen", + "calendarShareOwner": "Besitzer", + "calendarShareRead": "Alle Details lesen", + "calendarShareWrite": "Termine bearbeiten", + "calendarShareWriteWithoutPrivate": "Ohne private Details bearbeiten", + "eventLabel": "Terminlabel", + "googleChatAvailable": "Verfügbar", + "googleChatDoNotDisturb": "Nicht stören", + "googleChatStatus": "Chatstatus", + "googleDeclineAll": "Alle kollidierenden Einladungen ablehnen", + "googleDeclineInvitations": "Überschneidende Einladungen ablehnen", + "googleDeclineMessage": "Ablehnungsnachricht", + "googleDeclineNew": "Neue Einladungen ablehnen", + "googleDeclineNone": "Nicht ablehnen", + "googleEventType": "Termintyp", + "googleFocusTime": "Fokuszeit", + "googleOutOfOffice": "Abwesend", + "googleRegularEvent": "Normaler Termin", + "googleStatusPrimaryOnly": "Google-Statusereignisse benötigen Ihren primären Google-Kalender.", + "googleWorkAtCustomLocation": "Eigener Ort", + "googleWorkAtHome": "Zu Hause", + "googleWorkAtOffice": "Im Büro", + "googleWorkLocationLabel": "Ortsbezeichnung", + "googleWorkingLocation": "Arbeitsort", + "loadOutlookCategories": "Outlook-Kategorien laden", + "manageCalendarSharing": "Kalenderfreigabe verwalten", + "outlookCategoriesUnavailable": "Outlook-Kategorien sind nicht verfügbar; bestehende Zuordnungen bleiben erhalten.", + "shareRecipientEmail": "E-Mail-Adresse des Empfängers", + "shareRole": "Zugriffsrolle", + "sharingPermissionUnavailable": "Freigabeberechtigungen sind für diesen Kalender nicht verfügbar.", + "sharingRefreshFailed": "Die Freigabe wurde geändert, aber die Berechtigungsliste konnte nicht aktualisiert werden. Laden Sie sie vor einer weiteren Änderung neu.", + "sharingRateLimited": "Die Freigabe ist vorübergehend begrenzt. Versuchen Sie es nach der Wartezeit erneut.", + "attachmentUploadRateLimited": "Das Hochladen von Anhängen ist vorübergehend begrenzt. Versuchen Sie es nach der Wartezeit erneut.", + "unknownEventLabel": "Unbekanntes Label ({id})", + "@unknownEventLabel": {"placeholders": {"id": {"type": "String"}}} } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 45cee748..eda65f03 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -205,7 +205,11 @@ }, "newEvent": "New event", "refreshCalendar": "Refresh calendar", - "openInProvider": "Open in provider", +"openInProvider": "Open in provider", + "linkedResources": "Linked resources", + "noLinkedResources": "No linked resources", + "attachments": "Attachments", + "attachmentsNotLoaded": "Attachments not loaded", "hideFromSchedule": "Hide from schedule", "showInSchedule": "Show in schedule", "noCalendarsSynced": "No calendars synced yet.", @@ -932,7 +936,8 @@ "moveToTop": "Move to top", "deleteTask": "Delete Task", "newSubtask": "New subtask", - "deleteTaskConfirmation": "Delete \"{title}\"?", + "deleteTaskConfirmation": "Delete \"{title}\"?", + "deleteAssignedTaskWarning": "This also deletes the original task in Google Docs or Chat Spaces.", "@deleteTaskConfirmation": { "placeholders": { "title": { @@ -1272,6 +1277,11 @@ "setAction": "Set", "removeFromMyCalendars": "Remove from my calendars", "removeAction": "Remove", + "removeOpenedSharedCalendarConfirmation": "Remove \"{title}\" from BusyMax? The owner's calendar and events will not be deleted.", + "@removeOpenedSharedCalendarConfirmation": { + "placeholders": {"title": {"type": "String"}} + }, + "attachmentUploadUnresolved": "The upload may have completed. Refresh attachments to resolve its outcome before uploading again.", "removeCalendarConfirmation": "Remove \"{title}\" from your Google Calendar list? The shared calendar and its events will not be deleted.", "@removeCalendarConfirmation": { "placeholders": { @@ -1579,7 +1589,10 @@ "searchFilters": "Search filters", "searchType": "Type", "searchDate": "Date", - "searchAnyDate": "Any date", + "searchAnyDate": "Any date (downloaded data)", + "eventLink": "Event link", + "eventLinkOpenFailed": "Could not open the link.", + "scheduleRangeIncomplete": "Some events could not be checked. Showing downloaded data.", "searchThisWeek": "This week", "searchCustomRange": "Custom range", "searchTaskStatus": "Task status", @@ -1591,5 +1604,44 @@ "searchClearFilters": "Clear filters", "searchFiltersAction": "Filters", "searchNoSources": "No search sources selected", - "searchClearText": "Clear text" + "searchClearText": "Clear text", + "openSharedCalendar": "Open shared calendar", + "manageCalendarSharing": "Manage calendar sharing", + "shareRecipientEmail": "Recipient email", + "shareRole": "Access role", + "addCalendarShare": "Add access", + "sharingRefreshFailed": "The sharing change succeeded, but the permission list could not be refreshed. Retry the list before making another change.", + "sharingRateLimited": "Sharing is temporarily rate-limited. Try again when the wait ends.", + "attachmentUploadRateLimited": "Attachment upload is temporarily rate-limited. Try again when the wait ends.", + "sharingPermissionUnavailable": "Sharing permissions are unavailable for this calendar.", + "calendarShareFreeBusy": "Free/busy only", + "calendarShareLimitedRead": "Read limited details", + "calendarShareRead": "Read all details", + "calendarShareWrite": "Edit events", + "calendarShareWriteWithoutPrivate": "Edit without private details", + "calendarShareOwner": "Owner", + "eventLabel": "Event label", + "loadOutlookCategories": "Load Outlook categories", + "outlookCategoriesUnavailable": "Outlook categories are unavailable; existing assignments are preserved.", + "googleEventType": "Event type", + "googleRegularEvent": "Regular event", + "googleFocusTime": "Focus time", + "googleOutOfOffice": "Out of office", + "googleWorkingLocation": "Working location", + "googleDeclineInvitations": "Decline overlapping invitations", + "googleDeclineNone": "Do not decline", + "googleDeclineNew": "Decline new invitations", + "googleDeclineAll": "Decline all conflicting invitations", + "googleDeclineMessage": "Decline message", + "googleChatStatus": "Chat status", + "googleChatAvailable": "Available", + "googleChatDoNotDisturb": "Do not disturb", + "googleWorkAtHome": "Home", + "googleWorkAtOffice": "Office", + "googleWorkAtCustomLocation": "Custom location", + "googleWorkLocationLabel": "Location label", + "googleStatusPrimaryOnly": "Google status events require your primary Google calendar.", + "unknownEventLabel": "Unknown label ({id})", + "@unknownEventLabel": {"placeholders": {"id": {"type": "String"}}}, + "calendarOwnerEmail": "Calendar owner email" } diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 3d1711bd..5867803c 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -171,7 +171,11 @@ }, "newEvent": "Nuevo evento", "refreshCalendar": "Actualizar calendario", - "openInProvider": "Abrir en el proveedor", +"openInProvider": "Abrir en el proveedor", + "linkedResources": "Recursos vinculados", + "noLinkedResources": "No hay recursos vinculados", + "attachments": "Archivos adjuntos", + "attachmentsNotLoaded": "Archivos adjuntos no cargados", "hideFromSchedule": "Ocultar de la agenda", "showInSchedule": "Mostrar en la agenda", "noCalendarsSynced": "Aún no hay calendarios sincronizados.", @@ -509,7 +513,8 @@ "moveToTop": "Mover al principio", "deleteTask": "Eliminar tarea", "newSubtask": "Nueva subtarea", - "deleteTaskConfirmation": "¿Eliminar \"{title}\"?", + "deleteTaskConfirmation": "¿Eliminar \"{title}\"?", + "deleteAssignedTaskWarning": "Esto también elimina la tarea original de Documentos de Google o los espacios de Chat.", "metadata": "Metadatos", "id": "ID", "etag": "ETag", @@ -889,6 +894,8 @@ "removeFromMyCalendars": "Quitar de mis calendarios", "removeAction": "Quitar", "removeCalendarConfirmation": "¿Quitar \"{title}\" de tu lista de Google Calendar? No se eliminarán el calendario compartido ni sus eventos.", + "removeOpenedSharedCalendarConfirmation": "¿Quitar \"{title}\" de BusyMax? No se eliminarán el calendario ni los eventos de su propietario.", + "attachmentUploadUnresolved": "Es posible que la carga se haya completado. Actualiza los adjuntos para comprobarlo antes de volver a subir el archivo.", "calendarCannotRemove": "Este calendario no se puede eliminar ni quitar de esta cuenta.", "calendarPendingChangesPreventRemoval": "Espera a que terminen de sincronizarse los cambios pendientes de este calendario antes de eliminarlo o quitarlo.", "taskListPendingChangesPreventRemoval": "Sincroniza la cuenta o resuelve los cambios bloqueados de esta lista de tareas en Diagnóstico antes de eliminar o quitar la lista.", @@ -1099,7 +1106,10 @@ "searchFilters": "Filtros de búsqueda", "searchType": "Tipo", "searchDate": "Fecha", - "searchAnyDate": "Cualquier fecha", + "searchAnyDate": "Cualquier fecha (datos descargados)", + "eventLink": "Enlace del evento", + "eventLinkOpenFailed": "No se pudo abrir el enlace.", + "scheduleRangeIncomplete": "No se pudieron comprobar algunos eventos. Se muestran los datos descargados.", "searchThisWeek": "Esta semana", "searchCustomRange": "Intervalo personalizado", "searchTaskStatus": "Estado de tareas", @@ -1114,5 +1124,44 @@ "searchClearText": "Borrar texto", "windowMinimize": "Minimizar", "windowMaximize": "Maximizar", - "windowRestore": "Restaurar" + "windowRestore": "Restaurar", + "openSharedCalendar": "Abrir calendario compartido", + "calendarOwnerEmail": "Correo del propietario del calendario", + "addCalendarShare": "Añadir acceso", + "calendarShareFreeBusy": "Solo libre/ocupado", + "calendarShareLimitedRead": "Leer detalles limitados", + "calendarShareOwner": "Propietario", + "calendarShareRead": "Leer todos los detalles", + "calendarShareWrite": "Editar eventos", + "calendarShareWriteWithoutPrivate": "Editar sin detalles privados", + "eventLabel": "Etiqueta del evento", + "googleChatAvailable": "Disponible", + "googleChatDoNotDisturb": "No molestar", + "googleChatStatus": "Estado del chat", + "googleDeclineAll": "Rechazar todas las invitaciones en conflicto", + "googleDeclineInvitations": "Rechazar invitaciones superpuestas", + "googleDeclineMessage": "Mensaje de rechazo", + "googleDeclineNew": "Rechazar invitaciones nuevas", + "googleDeclineNone": "No rechazar", + "googleEventType": "Tipo de evento", + "googleFocusTime": "Tiempo de concentración", + "googleOutOfOffice": "Fuera de la oficina", + "googleRegularEvent": "Evento normal", + "googleStatusPrimaryOnly": "Los eventos de estado de Google requieren tu calendario principal de Google.", + "googleWorkAtCustomLocation": "Ubicación personalizada", + "googleWorkAtHome": "Casa", + "googleWorkAtOffice": "Oficina", + "googleWorkLocationLabel": "Etiqueta de ubicación", + "googleWorkingLocation": "Lugar de trabajo", + "loadOutlookCategories": "Cargar categorías de Outlook", + "manageCalendarSharing": "Administrar uso compartido del calendario", + "outlookCategoriesUnavailable": "Las categorías de Outlook no están disponibles; se conservan las asignaciones actuales.", + "shareRecipientEmail": "Correo del destinatario", + "shareRole": "Rol de acceso", + "sharingPermissionUnavailable": "Los permisos para compartir no están disponibles para este calendario.", + "sharingRefreshFailed": "Se cambió el acceso, pero no se pudo actualizar la lista de permisos. Vuelve a cargarla antes de otro cambio.", + "sharingRateLimited": "El uso compartido está limitado temporalmente. Inténtalo de nuevo cuando termine la espera.", + "attachmentUploadRateLimited": "La carga de archivos adjuntos está limitada temporalmente. Inténtalo de nuevo cuando termine la espera.", + "unknownEventLabel": "Etiqueta desconocida ({id})", + "@unknownEventLabel": {"placeholders": {"id": {"type": "String"}}} } diff --git a/lib/l10n/app_et.arb b/lib/l10n/app_et.arb index 39578833..c107382b 100644 --- a/lib/l10n/app_et.arb +++ b/lib/l10n/app_et.arb @@ -171,7 +171,11 @@ }, "newEvent": "Uus sündmus", "refreshCalendar": "Värskenda kalendrit", - "openInProvider": "Ava teenuses", +"openInProvider": "Ava teenuses", + "linkedResources": "Seotud ressursid", + "noLinkedResources": "Seotud ressursse pole", + "attachments": "Manused", + "attachmentsNotLoaded": "Manuseid ei laaditud", "hideFromSchedule": "Peida ajakavast", "showInSchedule": "Kuva ajakavas", "noCalendarsSynced": "Ühtegi kalendrit pole veel sünkroonitud.", @@ -561,7 +565,8 @@ "moveToTop": "Teisalda kõige üles", "deleteTask": "Kustuta ülesanne", "newSubtask": "Uus alamülesanne", - "deleteTaskConfirmation": "Kas kustutada „{title}”?", + "deleteTaskConfirmation": "Kas kustutada „{title}”?", + "deleteAssignedTaskWarning": "See kustutab ka algse ülesande Google Docsis või Chati ruumides.", "@deleteTaskConfirmation": { "placeholders": { "title": { @@ -1004,6 +1009,8 @@ "removeFromMyCalendars": "Eemalda minu kalendritest", "removeAction": "Eemalda", "removeCalendarConfirmation": "Kas eemaldada „{title}” teie Google’i kalendrite loendist? Jagatud kalendrit ega selle sündmusi ei kustutata.", + "removeOpenedSharedCalendarConfirmation": "Kas eemaldada „{title}” BusyMaxist? Omaniku kalendrit ega sündmusi ei kustutata.", + "attachmentUploadUnresolved": "Üleslaadimine võis õnnestuda. Enne uut üleslaadimist värskendage manuseid ja kontrollige tulemust.", "calendarCannotRemove": "Seda kalendrit ei saa sellest kontost kustutada ega eemaldada.", "calendarPendingChangesPreventRemoval": "Enne kalendri kustutamist või eemaldamist oodake, kuni selle ootel muudatused on sünkroonitud.", "taskListPendingChangesPreventRemoval": "Enne loendi kustutamist või eemaldamist sünkroonige konto või lahendage selle ülesandeloendi blokeeritud muudatused jaotises Diagnostika.", @@ -1214,7 +1221,10 @@ "searchFilters": "Otsingufiltrid", "searchType": "Tüüp", "searchDate": "Kuupäev", - "searchAnyDate": "Kõik kuupäevad", + "searchAnyDate": "Kõik kuupäevad (allalaaditud andmed)", + "eventLink": "Sündmuse link", + "eventLinkOpenFailed": "Linki ei õnnestunud avada.", + "scheduleRangeIncomplete": "Mõnda sündmust ei saanud kontrollida. Kuvatakse allalaaditud andmed.", "searchThisWeek": "See nädal", "searchCustomRange": "Kohandatud vahemik", "searchTaskStatus": "Ülesande olek", @@ -1229,5 +1239,44 @@ "searchClearText": "Tühjenda tekst", "windowMinimize": "Minimeeri", "windowMaximize": "Maksimeeri", - "windowRestore": "Taasta" + "windowRestore": "Taasta", + "openSharedCalendar": "Ava jagatud kalender", + "calendarOwnerEmail": "Kalendri omaniku e-posti aadress", + "addCalendarShare": "Lisa juurdepääs", + "calendarShareFreeBusy": "Ainult vaba/hõivatud", + "calendarShareLimitedRead": "Loe piiratud üksikasju", + "calendarShareOwner": "Omanik", + "calendarShareRead": "Loe kõiki üksikasju", + "calendarShareWrite": "Muuda sündmusi", + "calendarShareWriteWithoutPrivate": "Muuda ilma privaatsete üksikasjadeta", + "eventLabel": "Sündmuse silt", + "googleChatAvailable": "Saadaval", + "googleChatDoNotDisturb": "Ära sega", + "googleChatStatus": "Vestluse olek", + "googleDeclineAll": "Keeldu kõigist kattuvatest kutsetest", + "googleDeclineInvitations": "Keeldu kattuvatest kutsetest", + "googleDeclineMessage": "Keeldumissõnum", + "googleDeclineNew": "Keeldu uutest kutsetest", + "googleDeclineNone": "Ära keeldu", + "googleEventType": "Sündmuse tüüp", + "googleFocusTime": "Keskendumisaeg", + "googleOutOfOffice": "Kontorist väljas", + "googleRegularEvent": "Tavaline sündmus", + "googleStatusPrimaryOnly": "Google’i olekusündmused nõuavad peamist Google’i kalendrit.", + "googleWorkAtCustomLocation": "Kohandatud asukoht", + "googleWorkAtHome": "Kodus", + "googleWorkAtOffice": "Kontoris", + "googleWorkLocationLabel": "Asukoha nimi", + "googleWorkingLocation": "Tööasukoht", + "loadOutlookCategories": "Laadi Outlooki kategooriad", + "manageCalendarSharing": "Halda kalendri jagamist", + "outlookCategoriesUnavailable": "Outlooki kategooriad pole saadaval; senised määrangud säilivad.", + "shareRecipientEmail": "Saaja e-posti aadress", + "shareRole": "Juurdepääsu roll", + "sharingPermissionUnavailable": "Selle kalendri jagamisõigused pole saadaval.", + "sharingRefreshFailed": "Jagamine muudeti, kuid õiguste loendit ei saanud värskendada. Värskenda loendit enne järgmist muudatust.", + "sharingRateLimited": "Jagamine on ajutiselt piiratud. Proovi pärast ooteaja lõppu uuesti.", + "attachmentUploadRateLimited": "Manuste üleslaadimine on ajutiselt piiratud. Proovi pärast ooteaja lõppu uuesti.", + "unknownEventLabel": "Tundmatu silt ({id})", + "@unknownEventLabel": {"placeholders": {"id": {"type": "String"}}} } diff --git a/lib/l10n/app_fa.arb b/lib/l10n/app_fa.arb index d94aaff0..fbe013db 100644 --- a/lib/l10n/app_fa.arb +++ b/lib/l10n/app_fa.arb @@ -172,7 +172,11 @@ }, "newEvent": "رویداد جدید", "refreshCalendar": "تازه‌سازی تقویم", - "openInProvider": "باز کردن در سرویس", +"openInProvider": "باز کردن در سرویس", + "linkedResources": "منابع پیوندشده", + "noLinkedResources": "منبع پیوندشده‌ای وجود ندارد", + "attachments": "پیوست‌ها", + "attachmentsNotLoaded": "پیوست‌ها بارگیری نشده‌اند", "hideFromSchedule": "پنهان کردن از برنامه", "showInSchedule": "نمایش در برنامه", "noCalendarsSynced": "هنوز هیچ تقویمی همگام نشده است.", @@ -472,7 +476,8 @@ "moveToTop": "انتقال به بالاترین جایگاه", "deleteTask": "حذف کار", "newSubtask": "زیرکار جدید", - "deleteTaskConfirmation": "«⁨⁨{title}⁩⁩» حذف شود؟", + "deleteTaskConfirmation": "«⁨⁨{title}⁩⁩» حذف شود؟", + "deleteAssignedTaskWarning": "این کار، وظیفهٔ اصلی را نیز در Google Docs یا فضاهای Chat حذف می‌کند.", "metadata": "فراداده", "id": "شناسه", "etag": "ETag", @@ -895,6 +900,8 @@ "removeFromMyCalendars": "حذف از تقویم‌های من", "removeAction": "حذف", "removeCalendarConfirmation": "«⁨{title}⁩» از فهرست تقویم Google شما حذف شود؟ تقویم اشتراکی و رویدادهای آن حذف نخواهند شد.", + "removeOpenedSharedCalendarConfirmation": "«⁨{title}⁩» از BusyMax حذف شود؟ تقویم و رویدادهای مالک حذف نخواهند شد.", + "attachmentUploadUnresolved": "ممکن است بارگذاری کامل شده باشد. پیش از بارگذاری دوباره، پیوست‌ها را تازه‌سازی کنید تا نتیجه مشخص شود.", "calendarCannotRemove": "این تقویم را نمی‌توان از این حساب حذف یا جدا کرد.", "calendarPendingChangesPreventRemoval": "پیش از حذف یا جدا کردن این تقویم، صبر کنید تغییرات در انتظار آن همگام شوند.", "taskListPendingChangesPreventRemoval": "پیش از حذف یا برداشتن فهرست، حساب را همگام‌سازی کنید یا تغییرات مسدودشدهٔ این فهرست کار را در «عیب‌یابی» برطرف کنید.", @@ -1110,7 +1117,10 @@ "searchFilters": "فیلترهای جستجو", "searchType": "نوع", "searchDate": "تاریخ", - "searchAnyDate": "هر تاریخی", + "searchAnyDate": "هر تاریخی (داده‌های بارگیری‌شده)", + "eventLink": "پیوند رویداد", + "eventLinkOpenFailed": "باز کردن پیوند ممکن نشد.", + "scheduleRangeIncomplete": "بررسی برخی رویدادها ممکن نشد. داده‌های بارگیری‌شده نمایش داده می‌شوند.", "searchThisWeek": "این هفته", "searchCustomRange": "بازهٔ دلخواه", "searchTaskStatus": "وضعیت کار", @@ -1125,5 +1135,44 @@ "searchClearText": "پاک کردن متن", "windowMinimize": "کوچک کردن", "windowMaximize": "بزرگ کردن", - "windowRestore": "بازیابی" + "windowRestore": "بازیابی", + "openSharedCalendar": "باز کردن تقویم اشتراکی", + "calendarOwnerEmail": "ایمیل مالک تقویم", + "addCalendarShare": "افزودن دسترسی", + "calendarShareFreeBusy": "فقط آزاد/مشغول", + "calendarShareLimitedRead": "خواندن جزئیات محدود", + "calendarShareOwner": "مالک", + "calendarShareRead": "خواندن همه جزئیات", + "calendarShareWrite": "ویرایش رویدادها", + "calendarShareWriteWithoutPrivate": "ویرایش بدون جزئیات خصوصی", + "eventLabel": "برچسب رویداد", + "googleChatAvailable": "در دسترس", + "googleChatDoNotDisturb": "مزاحم نشوید", + "googleChatStatus": "وضعیت گفتگو", + "googleDeclineAll": "رد همه دعوت‌های متداخل", + "googleDeclineInvitations": "رد دعوت‌های هم‌زمان", + "googleDeclineMessage": "پیام رد", + "googleDeclineNew": "رد دعوت‌های جدید", + "googleDeclineNone": "رد نکن", + "googleEventType": "نوع رویداد", + "googleFocusTime": "زمان تمرکز", + "googleOutOfOffice": "خارج از دفتر", + "googleRegularEvent": "رویداد عادی", + "googleStatusPrimaryOnly": "رویدادهای وضعیت Google به تقویم اصلی Google نیاز دارند.", + "googleWorkAtCustomLocation": "مکان سفارشی", + "googleWorkAtHome": "خانه", + "googleWorkAtOffice": "دفتر", + "googleWorkLocationLabel": "عنوان مکان", + "googleWorkingLocation": "محل کار", + "loadOutlookCategories": "بارگیری دسته‌های Outlook", + "manageCalendarSharing": "مدیریت اشتراک‌گذاری تقویم", + "outlookCategoriesUnavailable": "دسته‌های Outlook در دسترس نیستند؛ انتساب‌های موجود حفظ می‌شوند.", + "shareRecipientEmail": "ایمیل گیرنده", + "shareRole": "نقش دسترسی", + "sharingPermissionUnavailable": "مجوزهای اشتراک‌گذاری برای این تقویم در دسترس نیست.", + "sharingRefreshFailed": "تغییر اشتراک‌گذاری انجام شد، اما فهرست مجوزها به‌روز نشد. پیش از تغییر دیگر فهرست را دوباره بارگیری کنید.", + "sharingRateLimited": "اشتراک‌گذاری موقتاً محدود شده است. پس از پایان زمان انتظار دوباره تلاش کنید.", + "attachmentUploadRateLimited": "بارگذاری پیوست‌ها موقتاً محدود شده است. پس از پایان زمان انتظار دوباره تلاش کنید.", + "unknownEventLabel": "برچسب ناشناخته (⁨{id}⁩)", + "@unknownEventLabel": {"placeholders": {"id": {"type": "String"}}} } diff --git a/lib/l10n/app_fi.arb b/lib/l10n/app_fi.arb index 192022a9..7ff7093b 100644 --- a/lib/l10n/app_fi.arb +++ b/lib/l10n/app_fi.arb @@ -171,7 +171,11 @@ }, "newEvent": "Uusi tapahtuma", "refreshCalendar": "Päivitä kalenteri", - "openInProvider": "Avaa palvelussa", +"openInProvider": "Avaa palvelussa", + "linkedResources": "Linkitetyt resurssit", + "noLinkedResources": "Ei linkitettyjä resursseja", + "attachments": "Liitteet", + "attachmentsNotLoaded": "Liitteitä ei ladattu", "hideFromSchedule": "Piilota aikataulusta", "showInSchedule": "Näytä aikataulussa", "noCalendarsSynced": "Kalentereita ei ole vielä synkronoitu.", @@ -467,7 +471,8 @@ "moveToTop": "Siirrä ylimmäksi", "deleteTask": "Poista tehtävä", "newSubtask": "Uusi alitehtävä", - "deleteTaskConfirmation": "Poistetaanko \"{title}\"?", + "deleteTaskConfirmation": "Poistetaanko \"{title}\"?", + "deleteAssignedTaskWarning": "Tämä poistaa myös alkuperäisen tehtävän Google Docsista tai Chat-tiloista.", "metadata": "Metatiedot", "id": "Tunnus", "etag": "ETag", @@ -826,6 +831,8 @@ "removeFromMyCalendars": "Poista omista kalentereistani", "removeAction": "Poista", "removeCalendarConfirmation": "Poistetaanko ”{title}” Google Kalenterin luettelostasi? Jaettua kalenteria tai sen tapahtumia ei poisteta.", + "removeOpenedSharedCalendarConfirmation": "Poistetaanko ”{title}” BusyMaxista? Omistajan kalenteria tai tapahtumia ei poisteta.", + "attachmentUploadUnresolved": "Lataus saattoi onnistua. Päivitä liitteet ja tarkista tulos ennen uutta latausta.", "calendarCannotRemove": "Tätä kalenteria ei voi poistaa tai irrottaa tästä tilistä.", "calendarPendingChangesPreventRemoval": "Odota tämän kalenterin odottavien muutosten synkronointia ennen poistamista tai irrottamista.", "taskListPendingChangesPreventRemoval": "Synkronoi tili tai ratkaise tämän tehtäväluettelon estetyt muutokset Diagnostiikassa ennen luettelon poistamista.", @@ -1036,7 +1043,10 @@ "searchFilters": "Hakusuodattimet", "searchType": "Tyyppi", "searchDate": "Päivämäärä", - "searchAnyDate": "Kaikki päivämäärät", + "searchAnyDate": "Kaikki päivämäärät (ladatut tiedot)", + "eventLink": "Tapahtuman linkki", + "eventLinkOpenFailed": "Linkkiä ei voitu avata.", + "scheduleRangeIncomplete": "Kaikkia tapahtumia ei voitu tarkistaa. Näytetään ladatut tiedot.", "searchThisWeek": "Tämä viikko", "searchCustomRange": "Mukautettu aikaväli", "searchTaskStatus": "Tehtävän tila", @@ -1051,5 +1061,44 @@ "searchClearText": "Tyhjennä teksti", "windowMinimize": "Pienennä", "windowMaximize": "Suurenna", - "windowRestore": "Palauta" + "windowRestore": "Palauta", + "openSharedCalendar": "Avaa jaettu kalenteri", + "calendarOwnerEmail": "Kalenterin omistajan sähköpostiosoite", + "addCalendarShare": "Lisää käyttöoikeus", + "calendarShareFreeBusy": "Vain vapaa/varattu", + "calendarShareLimitedRead": "Lue rajatut tiedot", + "calendarShareOwner": "Omistaja", + "calendarShareRead": "Lue kaikki tiedot", + "calendarShareWrite": "Muokkaa tapahtumia", + "calendarShareWriteWithoutPrivate": "Muokkaa ilman yksityisiä tietoja", + "eventLabel": "Tapahtuman tunniste", + "googleChatAvailable": "Saatavilla", + "googleChatDoNotDisturb": "Älä häiritse", + "googleChatStatus": "Keskustelun tila", + "googleDeclineAll": "Hylkää kaikki päällekkäiset kutsut", + "googleDeclineInvitations": "Hylkää päällekkäiset kutsut", + "googleDeclineMessage": "Hylkäysviesti", + "googleDeclineNew": "Hylkää uudet kutsut", + "googleDeclineNone": "Älä hylkää", + "googleEventType": "Tapahtuman tyyppi", + "googleFocusTime": "Keskittymisaika", + "googleOutOfOffice": "Poissa toimistolta", + "googleRegularEvent": "Tavallinen tapahtuma", + "googleStatusPrimaryOnly": "Googlen tilatapahtumat vaativat ensisijaisen Google-kalenterin.", + "googleWorkAtCustomLocation": "Mukautettu sijainti", + "googleWorkAtHome": "Kotona", + "googleWorkAtOffice": "Toimistolla", + "googleWorkLocationLabel": "Sijainnin nimi", + "googleWorkingLocation": "Työskentelypaikka", + "loadOutlookCategories": "Lataa Outlook-luokat", + "manageCalendarSharing": "Hallitse kalenterin jakamista", + "outlookCategoriesUnavailable": "Outlook-luokat eivät ole saatavilla; aiemmat luokitukset säilyvät.", + "shareRecipientEmail": "Vastaanottajan sähköposti", + "shareRole": "Käyttöoikeusrooli", + "sharingPermissionUnavailable": "Tämän kalenterin jako-oikeudet eivät ole saatavilla.", + "sharingRefreshFailed": "Jako muutettiin, mutta oikeusluetteloa ei voitu päivittää. Lataa luettelo uudelleen ennen seuraavaa muutosta.", + "sharingRateLimited": "Jakamista on rajoitettu tilapäisesti. Yritä uudelleen odotusajan päätyttyä.", + "attachmentUploadRateLimited": "Liitteiden lataamista on rajoitettu tilapäisesti. Yritä uudelleen odotusajan päätyttyä.", + "unknownEventLabel": "Tuntematon tunniste ({id})", + "@unknownEventLabel": {"placeholders": {"id": {"type": "String"}}} } diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index c4309433..aef0384a 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -171,7 +171,11 @@ }, "newEvent": "Nouvel événement", "refreshCalendar": "Actualiser le calendrier", - "openInProvider": "Ouvrir dans le service", +"openInProvider": "Ouvrir dans le service", + "linkedResources": "Ressources liées", + "noLinkedResources": "Aucune ressource liée", + "attachments": "Pièces jointes", + "attachmentsNotLoaded": "Pièces jointes non chargées", "hideFromSchedule": "Masquer du planning", "showInSchedule": "Afficher dans le planning", "noCalendarsSynced": "Aucun calendrier synchronisé.", @@ -509,7 +513,8 @@ "moveToTop": "Déplacer tout en haut", "deleteTask": "Supprimer la tâche", "newSubtask": "Nouvelle sous-tâche", - "deleteTaskConfirmation": "Supprimer « {title} » ?", + "deleteTaskConfirmation": "Supprimer « {title} » ?", + "deleteAssignedTaskWarning": "Cela supprime aussi la tâche d’origine dans Google Docs ou les espaces Chat.", "metadata": "Métadonnées", "id": "ID", "etag": "ETag", @@ -889,6 +894,8 @@ "removeFromMyCalendars": "Retirer de mes agendas", "removeAction": "Retirer", "removeCalendarConfirmation": "Retirer « {title} » de votre liste Google Agenda ? L’agenda partagé et ses événements ne seront pas supprimés.", + "removeOpenedSharedCalendarConfirmation": "Retirer « {title} » de BusyMax ? L’agenda et les événements du propriétaire ne seront pas supprimés.", + "attachmentUploadUnresolved": "L’envoi a peut-être réussi. Actualisez les pièces jointes pour vérifier le résultat avant de réessayer.", "calendarCannotRemove": "Ce calendrier ne peut pas être supprimé ou retiré de ce compte.", "calendarPendingChangesPreventRemoval": "Attendez que les modifications en attente de ce calendrier aient fini de se synchroniser avant de le supprimer ou de le retirer.", "taskListPendingChangesPreventRemoval": "Synchronisez le compte ou résolvez les modifications bloquées de cette liste de tâches dans Diagnostics avant de supprimer ou de retirer la liste.", @@ -1099,7 +1106,10 @@ "searchFilters": "Filtres de recherche", "searchType": "Type d’élément", "searchDate": "Date de recherche", - "searchAnyDate": "Toutes les dates", + "searchAnyDate": "Toutes les dates (données téléchargées)", + "eventLink": "Lien de l’événement", + "eventLinkOpenFailed": "Impossible d’ouvrir le lien.", + "scheduleRangeIncomplete": "Certains événements n’ont pas pu être vérifiés. Les données téléchargées sont affichées.", "searchThisWeek": "Cette semaine", "searchCustomRange": "Période personnalisée", "searchTaskStatus": "État des tâches", @@ -1114,5 +1124,44 @@ "searchClearText": "Effacer le texte", "windowMinimize": "Réduire", "windowMaximize": "Agrandir", - "windowRestore": "Restaurer" + "windowRestore": "Restaurer", + "openSharedCalendar": "Ouvrir un calendrier partagé", + "calendarOwnerEmail": "Adresse e-mail du propriétaire du calendrier", + "addCalendarShare": "Ajouter un accès", + "calendarShareFreeBusy": "Disponibilité uniquement", + "calendarShareLimitedRead": "Lire les détails limités", + "calendarShareOwner": "Propriétaire", + "calendarShareRead": "Lire tous les détails", + "calendarShareWrite": "Modifier les événements", + "calendarShareWriteWithoutPrivate": "Modifier sans détails privés", + "eventLabel": "Étiquette de l’événement", + "googleChatAvailable": "Disponible", + "googleChatDoNotDisturb": "Ne pas déranger", + "googleChatStatus": "Statut de discussion", + "googleDeclineAll": "Refuser toutes les invitations conflictuelles", + "googleDeclineInvitations": "Refuser les invitations qui se chevauchent", + "googleDeclineMessage": "Message de refus", + "googleDeclineNew": "Refuser les nouvelles invitations", + "googleDeclineNone": "Ne pas refuser", + "googleEventType": "Type d’événement", + "googleFocusTime": "Temps de concentration", + "googleOutOfOffice": "Absent du bureau", + "googleRegularEvent": "Événement ordinaire", + "googleStatusPrimaryOnly": "Les événements de statut Google nécessitent votre agenda Google principal.", + "googleWorkAtCustomLocation": "Lieu personnalisé", + "googleWorkAtHome": "Domicile", + "googleWorkAtOffice": "Bureau", + "googleWorkLocationLabel": "Nom du lieu", + "googleWorkingLocation": "Lieu de travail", + "loadOutlookCategories": "Charger les catégories Outlook", + "manageCalendarSharing": "Gérer le partage de l’agenda", + "outlookCategoriesUnavailable": "Les catégories Outlook sont indisponibles; les affectations existantes sont conservées.", + "shareRecipientEmail": "Adresse e-mail du destinataire", + "shareRole": "Rôle d’accès", + "sharingPermissionUnavailable": "Les autorisations de partage sont indisponibles pour cet agenda.", + "sharingRefreshFailed": "Le partage a été modifié, mais la liste des autorisations n’a pas pu être actualisée. Rechargez-la avant une autre modification.", + "sharingRateLimited": "Le partage est temporairement limité. Réessayez après le délai d’attente.", + "attachmentUploadRateLimited": "L’envoi de pièces jointes est temporairement limité. Réessayez après le délai d’attente.", + "unknownEventLabel": "Étiquette inconnue ({id})", + "@unknownEventLabel": {"placeholders": {"id": {"type": "String"}}} } diff --git a/lib/l10n/app_hi.arb b/lib/l10n/app_hi.arb index 19585036..add6c855 100644 --- a/lib/l10n/app_hi.arb +++ b/lib/l10n/app_hi.arb @@ -171,7 +171,11 @@ }, "newEvent": "नया इवेंट", "refreshCalendar": "कैलेंडर रीफ़्रेश करें", - "openInProvider": "सेवा में खोलें", +"openInProvider": "सेवा में खोलें", + "linkedResources": "लिंक किए गए संसाधन", + "noLinkedResources": "कोई लिंक किया गया संसाधन नहीं", + "attachments": "अटैचमेंट", + "attachmentsNotLoaded": "अटैचमेंट लोड नहीं हुए", "hideFromSchedule": "शेड्यूल से छिपाएँ", "showInSchedule": "शेड्यूल में दिखाएँ", "noCalendarsSynced": "अभी तक कोई कैलेंडर सिंक नहीं हुआ है।", @@ -467,7 +471,8 @@ "moveToTop": "सबसे ऊपर ले जाएँ", "deleteTask": "कार्य मिटाएँ", "newSubtask": "नया उपकार्य", - "deleteTaskConfirmation": "“{title}” मिटाएँ?", + "deleteTaskConfirmation": "“{title}” मिटाएँ?", + "deleteAssignedTaskWarning": "इससे Google Docs या Chat Spaces में मूल टास्क भी मिट जाएगा।", "metadata": "मेटाडेटा", "id": "आईडी", "etag": "ETag", @@ -826,6 +831,8 @@ "removeFromMyCalendars": "मेरे कैलेंडर से हटाएँ", "removeAction": "हटाएँ", "removeCalendarConfirmation": "क्या \"{title}\" को आपकी Google Calendar सूची से हटाएँ? साझा कैलेंडर और उसके इवेंट नहीं मिटाए जाएँगे।", + "removeOpenedSharedCalendarConfirmation": "क्या \"{title}\" को BusyMax से हटाएँ? मालिक का कैलेंडर और उसके इवेंट नहीं मिटाए जाएँगे।", + "attachmentUploadUnresolved": "अपलोड पूरा हो गया हो सकता है। दोबारा अपलोड करने से पहले परिणाम जाँचने के लिए अटैचमेंट रीफ़्रेश करें।", "calendarCannotRemove": "इस कैलेंडर को इस खाते से हटाया या मिटाया नहीं जा सकता।", "calendarPendingChangesPreventRemoval": "कैलेंडर हटाने से पहले उसके लंबित बदलावों का सिंक पूरा होने की प्रतीक्षा करें।", "taskListPendingChangesPreventRemoval": "सूची को मिटाने या हटाने से पहले खाते को सिंक करें, या डायग्नोस्टिक्स में इस कार्य सूची के अवरुद्ध बदलावों का समाधान करें।", @@ -1036,7 +1043,10 @@ "searchFilters": "खोज फ़िल्टर", "searchType": "प्रकार", "searchDate": "तारीख", - "searchAnyDate": "कोई भी तारीख", + "searchAnyDate": "कोई भी तारीख (डाउनलोड किया गया डेटा)", + "eventLink": "इवेंट लिंक", + "eventLinkOpenFailed": "लिंक नहीं खोला जा सका।", + "scheduleRangeIncomplete": "कुछ इवेंट की जाँच नहीं हो सकी। डाउनलोड किया गया डेटा दिखाया जा रहा है।", "searchThisWeek": "इस सप्ताह", "searchCustomRange": "कस्टम अवधि", "searchTaskStatus": "कार्य स्थिति", @@ -1051,5 +1061,44 @@ "searchClearText": "पाठ साफ़ करें", "windowMinimize": "छोटा करें", "windowMaximize": "अधिकतम करें", - "windowRestore": "पुनर्स्थापित करें" + "windowRestore": "पुनर्स्थापित करें", + "openSharedCalendar": "साझा कैलेंडर खोलें", + "calendarOwnerEmail": "कैलेंडर स्वामी का ईमेल", + "addCalendarShare": "पहुँच जोड़ें", + "calendarShareFreeBusy": "केवल खाली/व्यस्त", + "calendarShareLimitedRead": "सीमित विवरण पढ़ें", + "calendarShareOwner": "स्वामी", + "calendarShareRead": "सभी विवरण पढ़ें", + "calendarShareWrite": "कार्यक्रम संपादित करें", + "calendarShareWriteWithoutPrivate": "निजी विवरण के बिना संपादित करें", + "eventLabel": "कार्यक्रम लेबल", + "googleChatAvailable": "उपलब्ध", + "googleChatDoNotDisturb": "परेशान न करें", + "googleChatStatus": "चैट स्थिति", + "googleDeclineAll": "सभी टकराने वाले आमंत्रण अस्वीकार करें", + "googleDeclineInvitations": "साथ आने वाले आमंत्रण अस्वीकार करें", + "googleDeclineMessage": "अस्वीकृति संदेश", + "googleDeclineNew": "नए आमंत्रण अस्वीकार करें", + "googleDeclineNone": "अस्वीकार न करें", + "googleEventType": "कार्यक्रम का प्रकार", + "googleFocusTime": "एकाग्रता का समय", + "googleOutOfOffice": "कार्यालय से बाहर", + "googleRegularEvent": "सामान्य कार्यक्रम", + "googleStatusPrimaryOnly": "Google स्थिति कार्यक्रम के लिए आपका मुख्य Google कैलेंडर आवश्यक है।", + "googleWorkAtCustomLocation": "कस्टम स्थान", + "googleWorkAtHome": "घर", + "googleWorkAtOffice": "कार्यालय", + "googleWorkLocationLabel": "स्थान का नाम", + "googleWorkingLocation": "कार्य स्थान", + "loadOutlookCategories": "Outlook श्रेणियाँ लोड करें", + "manageCalendarSharing": "कैलेंडर साझाकरण प्रबंधित करें", + "outlookCategoriesUnavailable": "Outlook श्रेणियाँ उपलब्ध नहीं हैं; मौजूदा श्रेणियाँ बनी रहेंगी।", + "shareRecipientEmail": "प्राप्तकर्ता का ईमेल", + "shareRole": "पहुँच की भूमिका", + "sharingPermissionUnavailable": "इस कैलेंडर के लिए साझाकरण अनुमतियाँ उपलब्ध नहीं हैं।", + "sharingRefreshFailed": "साझाकरण बदल गया, लेकिन अनुमतियों की सूची ताज़ा नहीं हो सकी। अगला बदलाव करने से पहले सूची फिर लोड करें।", + "sharingRateLimited": "साझाकरण अस्थायी रूप से सीमित है। प्रतीक्षा अवधि समाप्त होने पर फिर प्रयास करें।", + "attachmentUploadRateLimited": "अटैचमेंट अपलोड अस्थायी रूप से सीमित है। प्रतीक्षा अवधि समाप्त होने पर फिर प्रयास करें।", + "unknownEventLabel": "अज्ञात लेबल ({id})", + "@unknownEventLabel": {"placeholders": {"id": {"type": "String"}}} } diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb index c2f9194a..62bcfc43 100644 --- a/lib/l10n/app_it.arb +++ b/lib/l10n/app_it.arb @@ -171,7 +171,11 @@ }, "newEvent": "Nuovo evento", "refreshCalendar": "Aggiorna calendario", - "openInProvider": "Apri nel servizio", +"openInProvider": "Apri nel servizio", + "linkedResources": "Risorse collegate", + "noLinkedResources": "Nessuna risorsa collegata", + "attachments": "Allegati", + "attachmentsNotLoaded": "Allegati non caricati", "hideFromSchedule": "Nascondi dall’agenda", "showInSchedule": "Mostra nell’agenda", "noCalendarsSynced": "Nessun calendario ancora sincronizzato.", @@ -467,7 +471,8 @@ "moveToTop": "Sposta in cima", "deleteTask": "Elimina attività", "newSubtask": "Nuova sottoattività", - "deleteTaskConfirmation": "Eliminare «{title}»?", + "deleteTaskConfirmation": "Eliminare «{title}»?", + "deleteAssignedTaskWarning": "Questa operazione elimina anche l’attività originale in Google Docs o negli spazi di Chat.", "metadata": "Metadati", "id": "ID", "etag": "ETag", @@ -826,6 +831,8 @@ "removeFromMyCalendars": "Rimuovi dai miei calendari", "removeAction": "Rimuovi", "removeCalendarConfirmation": "Rimuovere \"{title}\" dall'elenco di Google Calendar? Il calendario condiviso e i relativi eventi non verranno eliminati.", + "removeOpenedSharedCalendarConfirmation": "Rimuovere \"{title}\" da BusyMax? Il calendario e gli eventi del proprietario non verranno eliminati.", + "attachmentUploadUnresolved": "Il caricamento potrebbe essere riuscito. Aggiorna gli allegati per verificarlo prima di riprovare.", "calendarCannotRemove": "Non è possibile eliminare o rimuovere questo calendario da questo account.", "calendarPendingChangesPreventRemoval": "Attendi il completamento della sincronizzazione delle modifiche in sospeso del calendario prima di eliminarlo o rimuoverlo.", "taskListPendingChangesPreventRemoval": "Sincronizza l’account oppure risolvi le modifiche bloccate di questo elenco di attività in Diagnostica prima di eliminare o rimuovere l’elenco.", @@ -1036,7 +1043,10 @@ "searchFilters": "Filtri di ricerca", "searchType": "Tipo", "searchDate": "Data", - "searchAnyDate": "Qualsiasi data", + "searchAnyDate": "Qualsiasi data (dati scaricati)", + "eventLink": "Link dell’evento", + "eventLinkOpenFailed": "Impossibile aprire il link.", + "scheduleRangeIncomplete": "Alcuni eventi non sono stati verificati. Sono mostrati i dati scaricati.", "searchThisWeek": "Questa settimana", "searchCustomRange": "Intervallo personalizzato", "searchTaskStatus": "Stato attività", @@ -1051,5 +1061,44 @@ "searchClearText": "Cancella testo", "windowMinimize": "Riduci", "windowMaximize": "Ingrandisci", - "windowRestore": "Ripristina" + "windowRestore": "Ripristina", + "openSharedCalendar": "Apri calendario condiviso", + "calendarOwnerEmail": "Email del proprietario del calendario", + "addCalendarShare": "Aggiungi accesso", + "calendarShareFreeBusy": "Solo libero/occupato", + "calendarShareLimitedRead": "Leggi dettagli limitati", + "calendarShareOwner": "Proprietario", + "calendarShareRead": "Leggi tutti i dettagli", + "calendarShareWrite": "Modifica eventi", + "calendarShareWriteWithoutPrivate": "Modifica senza dettagli privati", + "eventLabel": "Etichetta evento", + "googleChatAvailable": "Disponibile", + "googleChatDoNotDisturb": "Non disturbare", + "googleChatStatus": "Stato chat", + "googleDeclineAll": "Rifiuta tutti gli inviti in conflitto", + "googleDeclineInvitations": "Rifiuta gli inviti sovrapposti", + "googleDeclineMessage": "Messaggio di rifiuto", + "googleDeclineNew": "Rifiuta i nuovi inviti", + "googleDeclineNone": "Non rifiutare", + "googleEventType": "Tipo di evento", + "googleFocusTime": "Tempo di concentrazione", + "googleOutOfOffice": "Fuori sede", + "googleRegularEvent": "Evento normale", + "googleStatusPrimaryOnly": "Gli eventi di stato Google richiedono il calendario Google principale.", + "googleWorkAtCustomLocation": "Luogo personalizzato", + "googleWorkAtHome": "Casa", + "googleWorkAtOffice": "Ufficio", + "googleWorkLocationLabel": "Nome del luogo", + "googleWorkingLocation": "Luogo di lavoro", + "loadOutlookCategories": "Carica categorie Outlook", + "manageCalendarSharing": "Gestisci condivisione del calendario", + "outlookCategoriesUnavailable": "Le categorie Outlook non sono disponibili; le assegnazioni esistenti vengono conservate.", + "shareRecipientEmail": "Email del destinatario", + "shareRole": "Ruolo di accesso", + "sharingPermissionUnavailable": "Le autorizzazioni di condivisione non sono disponibili per questo calendario.", + "sharingRefreshFailed": "La condivisione è stata modificata, ma l’elenco delle autorizzazioni non è stato aggiornato. Ricaricalo prima di un’altra modifica.", + "sharingRateLimited": "La condivisione è temporaneamente limitata. Riprova al termine dell’attesa.", + "attachmentUploadRateLimited": "Il caricamento degli allegati è temporaneamente limitato. Riprova al termine dell’attesa.", + "unknownEventLabel": "Etichetta sconosciuta ({id})", + "@unknownEventLabel": {"placeholders": {"id": {"type": "String"}}} } diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index 35fc5fee..462d5c41 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -171,7 +171,11 @@ }, "newEvent": "新しい予定", "refreshCalendar": "カレンダーを更新", - "openInProvider": "サービスで開く", +"openInProvider": "サービスで開く", + "linkedResources": "リンクされたリソース", + "noLinkedResources": "リンクされたリソースはありません", + "attachments": "添付ファイル", + "attachmentsNotLoaded": "添付ファイルは読み込まれていません", "hideFromSchedule": "スケジュールから非表示", "showInSchedule": "スケジュールに表示", "noCalendarsSynced": "同期済みのカレンダーはまだありません。", @@ -467,7 +471,8 @@ "moveToTop": "一番上に移動", "deleteTask": "タスクを削除", "newSubtask": "新しいサブタスク", - "deleteTaskConfirmation": "「{title}」を削除しますか?", + "deleteTaskConfirmation": "「{title}」を削除しますか?", + "deleteAssignedTaskWarning": "Google ドキュメントまたは Chat スペースの元のタスクも削除されます。", "metadata": "メタデータ", "id": "ID", "etag": "ETag", @@ -826,6 +831,8 @@ "removeFromMyCalendars": "マイカレンダーから削除", "removeAction": "削除", "removeCalendarConfirmation": "「{title}」を Google カレンダーのリストから削除しますか?共有カレンダーとその予定は削除されません。", + "removeOpenedSharedCalendarConfirmation": "「{title}」を BusyMax から削除しますか?所有者のカレンダーと予定は削除されません。", + "attachmentUploadUnresolved": "アップロードが完了した可能性があります。再アップロードする前に添付ファイルを更新して結果を確認してください。", "calendarCannotRemove": "このカレンダーはこのアカウントから削除または解除できません。", "calendarPendingChangesPreventRemoval": "削除または解除する前に、このカレンダーの保留中の変更が同期されるまで待ってください。", "taskListPendingChangesPreventRemoval": "このリストを削除する前に、アカウントを同期するか、[診断]でこのタスクリストのブロックされた変更を解決してください。", @@ -1036,7 +1043,10 @@ "searchFilters": "検索フィルター", "searchType": "種類", "searchDate": "日付", - "searchAnyDate": "すべての日付", + "searchAnyDate": "すべての日付(ダウンロード済みデータ)", + "eventLink": "予定のリンク", + "eventLinkOpenFailed": "リンクを開けませんでした。", + "scheduleRangeIncomplete": "一部の予定を確認できませんでした。ダウンロード済みデータを表示しています。", "searchThisWeek": "今週", "searchCustomRange": "期間を指定", "searchTaskStatus": "タスクの状態", @@ -1051,5 +1061,44 @@ "searchClearText": "テキストを消去", "windowMinimize": "最小化", "windowMaximize": "最大化", - "windowRestore": "元に戻す" + "windowRestore": "元に戻す", + "openSharedCalendar": "共有カレンダーを開く", + "calendarOwnerEmail": "カレンダー所有者のメールアドレス", + "addCalendarShare": "アクセスを追加", + "calendarShareFreeBusy": "空き時間情報のみ", + "calendarShareLimitedRead": "限定された詳細を表示", + "calendarShareOwner": "所有者", + "calendarShareRead": "すべての詳細を表示", + "calendarShareWrite": "予定を編集", + "calendarShareWriteWithoutPrivate": "非公開の詳細を除いて編集", + "eventLabel": "予定のラベル", + "googleChatAvailable": "連絡可能", + "googleChatDoNotDisturb": "通知をミュート", + "googleChatStatus": "チャットの状態", + "googleDeclineAll": "競合する招待をすべて辞退", + "googleDeclineInvitations": "重なる招待を辞退", + "googleDeclineMessage": "辞退メッセージ", + "googleDeclineNew": "新しい招待を辞退", + "googleDeclineNone": "辞退しない", + "googleEventType": "予定の種類", + "googleFocusTime": "集中する時間", + "googleOutOfOffice": "不在", + "googleRegularEvent": "通常の予定", + "googleStatusPrimaryOnly": "Google のステータス予定にはメインの Google カレンダーが必要です。", + "googleWorkAtCustomLocation": "カスタムの場所", + "googleWorkAtHome": "自宅", + "googleWorkAtOffice": "オフィス", + "googleWorkLocationLabel": "場所の名前", + "googleWorkingLocation": "勤務場所", + "loadOutlookCategories": "Outlook カテゴリを読み込む", + "manageCalendarSharing": "カレンダーの共有を管理", + "outlookCategoriesUnavailable": "Outlook カテゴリを取得できません。既存の割り当ては保持されます。", + "shareRecipientEmail": "共有先のメールアドレス", + "shareRole": "アクセス権", + "sharingPermissionUnavailable": "このカレンダーの共有権限を取得できません。", + "sharingRefreshFailed": "共有は変更されましたが、権限一覧を更新できませんでした。次の変更前に一覧を再読み込みしてください。", + "sharingRateLimited": "共有は一時的に制限されています。待機時間が終了してから再試行してください。", + "attachmentUploadRateLimited": "添付ファイルのアップロードは一時的に制限されています。待機時間が終了してから再試行してください。", + "unknownEventLabel": "不明なラベル({id})", + "@unknownEventLabel": {"placeholders": {"id": {"type": "String"}}} } diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index 5ac3f372..e8ac4898 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -171,7 +171,11 @@ }, "newEvent": "새 일정", "refreshCalendar": "캘린더 새로 고침", - "openInProvider": "서비스에서 열기", +"openInProvider": "서비스에서 열기", + "linkedResources": "연결된 리소스", + "noLinkedResources": "연결된 리소스가 없습니다", + "attachments": "첨부 파일", + "attachmentsNotLoaded": "첨부 파일을 불러오지 못했습니다", "hideFromSchedule": "일정에서 숨기기", "showInSchedule": "일정에 표시", "noCalendarsSynced": "아직 동기화된 캘린더가 없습니다.", @@ -467,7 +471,8 @@ "moveToTop": "맨 위로 이동", "deleteTask": "할 일 삭제", "newSubtask": "새 하위 할 일", - "deleteTaskConfirmation": "“{title}” 항목을 삭제할까요?", + "deleteTaskConfirmation": "“{title}” 항목을 삭제할까요?", + "deleteAssignedTaskWarning": "Google Docs 또는 Chat 스페이스의 원본 할 일도 삭제됩니다.", "metadata": "메타데이터", "id": "ID", "etag": "ETag", @@ -826,6 +831,8 @@ "removeFromMyCalendars": "내 캘린더에서 삭제", "removeAction": "삭제", "removeCalendarConfirmation": "Google Calendar 목록에서 \"{title}\"을(를) 삭제할까요? 공유 캘린더와 일정은 삭제되지 않습니다.", + "removeOpenedSharedCalendarConfirmation": "BusyMax에서 \"{title}\"을(를) 제거할까요? 소유자의 캘린더와 일정은 삭제되지 않습니다.", + "attachmentUploadUnresolved": "업로드가 완료되었을 수 있습니다. 다시 업로드하기 전에 첨부 파일을 새로 고쳐 결과를 확인하세요.", "calendarCannotRemove": "이 캘린더는 이 계정에서 삭제하거나 제거할 수 없습니다.", "calendarPendingChangesPreventRemoval": "삭제하거나 제거하기 전에 이 캘린더의 보류 중인 변경사항이 동기화될 때까지 기다리세요.", "taskListPendingChangesPreventRemoval": "목록을 삭제하거나 제거하기 전에 계정을 동기화하거나 진단에서 이 작업 목록의 차단된 변경 사항을 해결하세요.", @@ -1036,7 +1043,10 @@ "searchFilters": "검색 필터", "searchType": "유형", "searchDate": "날짜", - "searchAnyDate": "모든 날짜", + "searchAnyDate": "모든 날짜(다운로드한 데이터)", + "eventLink": "일정 링크", + "eventLinkOpenFailed": "링크를 열 수 없습니다.", + "scheduleRangeIncomplete": "일부 일정을 확인할 수 없습니다. 다운로드한 데이터를 표시합니다.", "searchThisWeek": "이번 주", "searchCustomRange": "사용자 지정 기간", "searchTaskStatus": "작업 상태", @@ -1051,5 +1061,44 @@ "searchClearText": "텍스트 지우기", "windowMinimize": "최소화", "windowMaximize": "최대화", - "windowRestore": "복원" + "windowRestore": "복원", + "openSharedCalendar": "공유 캘린더 열기", + "calendarOwnerEmail": "캘린더 소유자 이메일", + "addCalendarShare": "액세스 추가", + "calendarShareFreeBusy": "한가함/바쁨만 보기", + "calendarShareLimitedRead": "제한된 세부정보 읽기", + "calendarShareOwner": "소유자", + "calendarShareRead": "모든 세부정보 읽기", + "calendarShareWrite": "일정 수정", + "calendarShareWriteWithoutPrivate": "비공개 정보 제외하고 수정", + "eventLabel": "일정 라벨", + "googleChatAvailable": "사용 가능", + "googleChatDoNotDisturb": "방해 금지", + "googleChatStatus": "채팅 상태", + "googleDeclineAll": "충돌하는 초대 모두 거절", + "googleDeclineInvitations": "겹치는 초대 거절", + "googleDeclineMessage": "거절 메시지", + "googleDeclineNew": "새 초대 거절", + "googleDeclineNone": "거절하지 않음", + "googleEventType": "일정 유형", + "googleFocusTime": "집중 시간", + "googleOutOfOffice": "부재중", + "googleRegularEvent": "일반 일정", + "googleStatusPrimaryOnly": "Google 상태 일정에는 기본 Google 캘린더가 필요합니다.", + "googleWorkAtCustomLocation": "사용자 지정 위치", + "googleWorkAtHome": "집", + "googleWorkAtOffice": "사무실", + "googleWorkLocationLabel": "위치 이름", + "googleWorkingLocation": "근무 위치", + "loadOutlookCategories": "Outlook 범주 불러오기", + "manageCalendarSharing": "캘린더 공유 관리", + "outlookCategoriesUnavailable": "Outlook 범주를 사용할 수 없습니다. 기존 지정은 유지됩니다.", + "shareRecipientEmail": "받는 사람 이메일", + "shareRole": "액세스 역할", + "sharingPermissionUnavailable": "이 캘린더의 공유 권한을 사용할 수 없습니다.", + "sharingRefreshFailed": "공유 변경은 완료되었지만 권한 목록을 새로 고치지 못했습니다. 다음 변경 전에 다시 불러오세요.", + "sharingRateLimited": "공유가 일시적으로 제한되었습니다. 대기 시간이 끝나면 다시 시도하세요.", + "attachmentUploadRateLimited": "첨부 파일 업로드가 일시적으로 제한되었습니다. 대기 시간이 끝나면 다시 시도하세요.", + "unknownEventLabel": "알 수 없는 라벨 ({id})", + "@unknownEventLabel": {"placeholders": {"id": {"type": "String"}}} } diff --git a/lib/l10n/app_pl.arb b/lib/l10n/app_pl.arb index 90e31104..c07782b8 100644 --- a/lib/l10n/app_pl.arb +++ b/lib/l10n/app_pl.arb @@ -294,7 +294,11 @@ }, "newEvent": "Nowe wydarzenie", "refreshCalendar": "Odśwież kalendarz", - "openInProvider": "Otwórz u dostawcy", +"openInProvider": "Otwórz u dostawcy", + "linkedResources": "Połączone zasoby", + "noLinkedResources": "Brak połączonych zasobów", + "attachments": "Załączniki", + "attachmentsNotLoaded": "Załączniki nie zostały wczytane", "hideFromSchedule": "Ukryj w harmonogramie", "showInSchedule": "Pokaż w harmonogramie", "noCalendarsSynced": "Nie zsynchronizowano jeszcze żadnych kalendarzy.", @@ -1022,7 +1026,8 @@ "moveToTop": "Przenieś na początek", "deleteTask": "Usuń zadanie", "newSubtask": "Nowe podzadanie", - "deleteTaskConfirmation": "Usunąć „{title}”?", + "deleteTaskConfirmation": "Usunąć „{title}”?", + "deleteAssignedTaskWarning": "Spowoduje to również usunięcie oryginalnego zadania w Dokumentach Google lub pokojach Chat.", "@deleteTaskConfirmation": { "placeholders": { "title": { @@ -1363,6 +1368,8 @@ "removeFromMyCalendars": "Usuń z moich kalendarzy", "removeAction": "Usuń", "removeCalendarConfirmation": "Usunąć „{title}” z listy Kalendarza Google? Udostępniony kalendarz i jego wydarzenia nie zostaną usunięte.", + "removeOpenedSharedCalendarConfirmation": "Usunąć „{title}” z BusyMax? Kalendarz właściciela i jego wydarzenia nie zostaną usunięte.", + "attachmentUploadUnresolved": "Przesyłanie mogło się zakończyć. Odśwież załączniki, aby sprawdzić wynik przed ponownym przesłaniem.", "@removeCalendarConfirmation": { "placeholders": { "title": { @@ -1746,7 +1753,10 @@ "searchFilters": "Filtry wyszukiwania", "searchType": "Typ", "searchDate": "Data", - "searchAnyDate": "Dowolna data", + "searchAnyDate": "Dowolna data (pobrane dane)", + "eventLink": "Link do wydarzenia", + "eventLinkOpenFailed": "Nie można otworzyć linku.", + "scheduleRangeIncomplete": "Nie można było sprawdzić części wydarzeń. Wyświetlane są pobrane dane.", "searchThisWeek": "Ten tydzień", "searchCustomRange": "Zakres niestandardowy", "searchTaskStatus": "Stan zadania", @@ -1761,5 +1771,44 @@ "searchClearText": "Wyczyść tekst", "windowMinimize": "Minimalizuj", "windowMaximize": "Maksymalizuj", - "windowRestore": "Przywróć" + "windowRestore": "Przywróć", + "openSharedCalendar": "Otwórz udostępniony kalendarz", + "calendarOwnerEmail": "Adres e-mail właściciela kalendarza", + "addCalendarShare": "Dodaj dostęp", + "calendarShareFreeBusy": "Tylko wolny/zajęty", + "calendarShareLimitedRead": "Odczyt ograniczonych szczegółów", + "calendarShareOwner": "Właściciel", + "calendarShareRead": "Odczyt wszystkich szczegółów", + "calendarShareWrite": "Edytowanie wydarzeń", + "calendarShareWriteWithoutPrivate": "Edytowanie bez prywatnych szczegółów", + "eventLabel": "Etykieta wydarzenia", + "googleChatAvailable": "Dostępny", + "googleChatDoNotDisturb": "Nie przeszkadzać", + "googleChatStatus": "Stan czatu", + "googleDeclineAll": "Odrzuć wszystkie kolidujące zaproszenia", + "googleDeclineInvitations": "Odrzuć nakładające się zaproszenia", + "googleDeclineMessage": "Wiadomość odmowy", + "googleDeclineNew": "Odrzuć nowe zaproszenia", + "googleDeclineNone": "Nie odrzucaj", + "googleEventType": "Typ wydarzenia", + "googleFocusTime": "Czas skupienia", + "googleOutOfOffice": "Poza biurem", + "googleRegularEvent": "Zwykłe wydarzenie", + "googleStatusPrimaryOnly": "Wydarzenia stanu Google wymagają głównego kalendarza Google.", + "googleWorkAtCustomLocation": "Niestandardowe miejsce", + "googleWorkAtHome": "Dom", + "googleWorkAtOffice": "Biuro", + "googleWorkLocationLabel": "Nazwa miejsca", + "googleWorkingLocation": "Miejsce pracy", + "loadOutlookCategories": "Wczytaj kategorie Outlooka", + "manageCalendarSharing": "Zarządzaj udostępnianiem kalendarza", + "outlookCategoriesUnavailable": "Kategorie Outlooka są niedostępne; istniejące przypisania zostają zachowane.", + "shareRecipientEmail": "Adres e-mail odbiorcy", + "shareRole": "Rola dostępu", + "sharingPermissionUnavailable": "Uprawnienia udostępniania nie są dostępne dla tego kalendarza.", + "sharingRefreshFailed": "Udostępnienie zmieniono, ale nie udało się odświeżyć listy uprawnień. Wczytaj ją ponownie przed następną zmianą.", + "sharingRateLimited": "Udostępnianie jest tymczasowo ograniczone. Spróbuj ponownie po upływie czasu oczekiwania.", + "attachmentUploadRateLimited": "Przesyłanie załączników jest tymczasowo ograniczone. Spróbuj ponownie po upływie czasu oczekiwania.", + "unknownEventLabel": "Nieznana etykieta ({id})", + "@unknownEventLabel": {"placeholders": {"id": {"type": "String"}}} } diff --git a/lib/l10n/app_pt_PT.arb b/lib/l10n/app_pt_PT.arb index 02acedbe..9d4c6f4e 100644 --- a/lib/l10n/app_pt_PT.arb +++ b/lib/l10n/app_pt_PT.arb @@ -171,7 +171,11 @@ }, "newEvent": "Novo evento", "refreshCalendar": "Atualizar calendário", - "openInProvider": "Abrir no serviço", +"openInProvider": "Abrir no serviço", + "linkedResources": "Recursos ligados", + "noLinkedResources": "Sem recursos ligados", + "attachments": "Anexos", + "attachmentsNotLoaded": "Anexos não carregados", "hideFromSchedule": "Ocultar da agenda", "showInSchedule": "Mostrar na agenda", "noCalendarsSynced": "Ainda não há calendários sincronizados.", @@ -467,7 +471,8 @@ "moveToTop": "Mover para o início", "deleteTask": "Eliminar tarefa", "newSubtask": "Nova subtarefa", - "deleteTaskConfirmation": "Eliminar «{title}»?", + "deleteTaskConfirmation": "Eliminar «{title}»?", + "deleteAssignedTaskWarning": "Isto também elimina a tarefa original no Google Docs ou nos espaços do Chat.", "metadata": "Metadados", "id": "ID", "etag": "ETag", @@ -826,6 +831,8 @@ "removeFromMyCalendars": "Remover dos meus calendários", "removeAction": "Remover", "removeCalendarConfirmation": "Remover \"{title}\" da sua lista do Google Calendar? O calendário partilhado e os respetivos eventos não serão eliminados.", + "removeOpenedSharedCalendarConfirmation": "Remover \"{title}\" do BusyMax? O calendário e os eventos do proprietário não serão eliminados.", + "attachmentUploadUnresolved": "O carregamento pode ter sido concluído. Atualize os anexos para confirmar antes de carregar novamente.", "calendarCannotRemove": "Não é possível eliminar nem remover este calendário desta conta.", "calendarPendingChangesPreventRemoval": "Aguarde que as alterações pendentes deste calendário terminem de sincronizar antes de o eliminar ou remover.", "taskListPendingChangesPreventRemoval": "Sincronize a conta ou resolva as alterações bloqueadas desta lista de tarefas em Diagnóstico antes de eliminar ou remover a lista.", @@ -1036,7 +1043,10 @@ "searchFilters": "Filtros de pesquisa", "searchType": "Tipo", "searchDate": "Data", - "searchAnyDate": "Qualquer data", + "searchAnyDate": "Qualquer data (dados transferidos)", + "eventLink": "Ligação do evento", + "eventLinkOpenFailed": "Não foi possível abrir a ligação.", + "scheduleRangeIncomplete": "Não foi possível verificar alguns eventos. São apresentados os dados transferidos.", "searchThisWeek": "Esta semana", "searchCustomRange": "Intervalo personalizado", "searchTaskStatus": "Estado das tarefas", @@ -1051,5 +1061,44 @@ "searchClearText": "Limpar texto", "windowMinimize": "Minimizar", "windowMaximize": "Maximizar", - "windowRestore": "Restaurar" + "windowRestore": "Restaurar", + "openSharedCalendar": "Abrir calendário partilhado", + "calendarOwnerEmail": "E-mail do proprietário do calendário", + "addCalendarShare": "Adicionar acesso", + "calendarShareFreeBusy": "Apenas livre/ocupado", + "calendarShareLimitedRead": "Ler detalhes limitados", + "calendarShareOwner": "Proprietário", + "calendarShareRead": "Ler todos os detalhes", + "calendarShareWrite": "Editar eventos", + "calendarShareWriteWithoutPrivate": "Editar sem detalhes privados", + "eventLabel": "Etiqueta do evento", + "googleChatAvailable": "Disponível", + "googleChatDoNotDisturb": "Não incomodar", + "googleChatStatus": "Estado da conversa", + "googleDeclineAll": "Recusar todos os convites em conflito", + "googleDeclineInvitations": "Recusar convites sobrepostos", + "googleDeclineMessage": "Mensagem de recusa", + "googleDeclineNew": "Recusar novos convites", + "googleDeclineNone": "Não recusar", + "googleEventType": "Tipo de evento", + "googleFocusTime": "Tempo de concentração", + "googleOutOfOffice": "Fora do escritório", + "googleRegularEvent": "Evento normal", + "googleStatusPrimaryOnly": "Os eventos de estado do Google exigem o calendário principal do Google.", + "googleWorkAtCustomLocation": "Local personalizado", + "googleWorkAtHome": "Casa", + "googleWorkAtOffice": "Escritório", + "googleWorkLocationLabel": "Nome do local", + "googleWorkingLocation": "Local de trabalho", + "loadOutlookCategories": "Carregar categorias do Outlook", + "manageCalendarSharing": "Gerir partilha do calendário", + "outlookCategoriesUnavailable": "As categorias do Outlook não estão disponíveis; as atribuições existentes são mantidas.", + "shareRecipientEmail": "E-mail do destinatário", + "shareRole": "Papel de acesso", + "sharingPermissionUnavailable": "As permissões de partilha não estão disponíveis para este calendário.", + "sharingRefreshFailed": "A partilha foi alterada, mas a lista de permissões não foi atualizada. Recarregue-a antes de fazer outra alteração.", + "sharingRateLimited": "A partilha está temporariamente limitada. Volte a tentar quando terminar o tempo de espera.", + "attachmentUploadRateLimited": "O carregamento de anexos está temporariamente limitado. Volte a tentar quando terminar o tempo de espera.", + "unknownEventLabel": "Etiqueta desconhecida ({id})", + "@unknownEventLabel": {"placeholders": {"id": {"type": "String"}}} } diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 134e17c7..4092bbf0 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -171,7 +171,11 @@ }, "newEvent": "Новое событие", "refreshCalendar": "Обновить календарь", - "openInProvider": "Открыть в сервисе", +"openInProvider": "Открыть в сервисе", + "linkedResources": "Связанные ресурсы", + "noLinkedResources": "Нет связанных ресурсов", + "attachments": "Вложения", + "attachmentsNotLoaded": "Вложения не загружены", "hideFromSchedule": "Скрыть из расписания", "showInSchedule": "Показывать в расписании", "noCalendarsSynced": "Синхронизированных календарей пока нет.", @@ -467,7 +471,8 @@ "moveToTop": "Переместить в самый верх", "deleteTask": "Удалить задачу", "newSubtask": "Новая подзадача", - "deleteTaskConfirmation": "Удалить «{title}»?", + "deleteTaskConfirmation": "Удалить «{title}»?", + "deleteAssignedTaskWarning": "Это также удалит исходную задачу в Google Документах или пространствах Chat.", "metadata": "Метаданные", "id": "Идентификатор", "etag": "ETag", @@ -826,6 +831,8 @@ "removeFromMyCalendars": "Удалить из моих календарей", "removeAction": "Удалить", "removeCalendarConfirmation": "Удалить «{title}» из вашего списка Google Календаря? Общий календарь и его события удалены не будут.", + "removeOpenedSharedCalendarConfirmation": "Убрать «{title}» из BusyMax? Календарь владельца и его события не будут удалены.", + "attachmentUploadUnresolved": "Загрузка могла завершиться. Обновите список вложений, чтобы проверить результат перед повторной загрузкой.", "calendarCannotRemove": "Этот календарь нельзя удалить или убрать из аккаунта.", "calendarPendingChangesPreventRemoval": "Дождитесь синхронизации ожидающих изменений этого календаря перед удалением или отключением.", "taskListPendingChangesPreventRemoval": "Перед удалением списка синхронизируйте учётную запись или устраните заблокированные изменения этого списка задач в разделе «Диагностика».", @@ -1036,7 +1043,10 @@ "searchFilters": "Фильтры поиска", "searchType": "Тип", "searchDate": "Дата", - "searchAnyDate": "Любая дата", + "searchAnyDate": "Любая дата (загруженные данные)", + "eventLink": "Ссылка на событие", + "eventLinkOpenFailed": "Не удалось открыть ссылку.", + "scheduleRangeIncomplete": "Не удалось проверить некоторые события. Показаны загруженные данные.", "searchThisWeek": "Эта неделя", "searchCustomRange": "Свой диапазон", "searchTaskStatus": "Статус задач", @@ -1051,5 +1061,44 @@ "searchClearText": "Очистить текст", "windowMinimize": "Свернуть", "windowMaximize": "Развернуть", - "windowRestore": "Восстановить" + "windowRestore": "Восстановить", + "openSharedCalendar": "Открыть общий календарь", + "calendarOwnerEmail": "Адрес электронной почты владельца календаря", + "addCalendarShare": "Добавить доступ", + "calendarShareFreeBusy": "Только занятость", + "calendarShareLimitedRead": "Просмотр ограниченных сведений", + "calendarShareOwner": "Владелец", + "calendarShareRead": "Просмотр всех сведений", + "calendarShareWrite": "Изменение событий", + "calendarShareWriteWithoutPrivate": "Изменение без личных сведений", + "eventLabel": "Метка события", + "googleChatAvailable": "Доступен", + "googleChatDoNotDisturb": "Не беспокоить", + "googleChatStatus": "Статус чата", + "googleDeclineAll": "Отклонять все пересекающиеся приглашения", + "googleDeclineInvitations": "Отклонять пересекающиеся приглашения", + "googleDeclineMessage": "Сообщение об отклонении", + "googleDeclineNew": "Отклонять новые приглашения", + "googleDeclineNone": "Не отклонять", + "googleEventType": "Тип события", + "googleFocusTime": "Время концентрации", + "googleOutOfOffice": "Вне офиса", + "googleRegularEvent": "Обычное событие", + "googleStatusPrimaryOnly": "Для событий статуса Google нужен основной календарь Google.", + "googleWorkAtCustomLocation": "Другое место", + "googleWorkAtHome": "Дома", + "googleWorkAtOffice": "В офисе", + "googleWorkLocationLabel": "Название места", + "googleWorkingLocation": "Рабочее место", + "loadOutlookCategories": "Загрузить категории Outlook", + "manageCalendarSharing": "Управление доступом к календарю", + "outlookCategoriesUnavailable": "Категории Outlook недоступны; существующие назначения сохранены.", + "shareRecipientEmail": "Адрес получателя", + "shareRole": "Роль доступа", + "sharingPermissionUnavailable": "Разрешения на общий доступ к этому календарю недоступны.", + "sharingRefreshFailed": "Доступ изменён, но список разрешений не обновился. Обновите его перед следующим изменением.", + "sharingRateLimited": "Общий доступ временно ограничен. Повторите попытку после окончания ожидания.", + "attachmentUploadRateLimited": "Загрузка вложений временно ограничена. Повторите попытку после окончания ожидания.", + "unknownEventLabel": "Неизвестная метка ({id})", + "@unknownEventLabel": {"placeholders": {"id": {"type": "String"}}} } diff --git a/lib/l10n/app_vi.arb b/lib/l10n/app_vi.arb index 64ddaf90..20387d5f 100644 --- a/lib/l10n/app_vi.arb +++ b/lib/l10n/app_vi.arb @@ -171,7 +171,11 @@ }, "newEvent": "Sự kiện mới", "refreshCalendar": "Làm mới lịch", - "openInProvider": "Mở trong dịch vụ", +"openInProvider": "Mở trong dịch vụ", + "linkedResources": "Tài nguyên liên kết", + "noLinkedResources": "Không có tài nguyên liên kết", + "attachments": "Tệp đính kèm", + "attachmentsNotLoaded": "Chưa tải tệp đính kèm", "hideFromSchedule": "Ẩn khỏi lịch biểu", "showInSchedule": "Hiện trong lịch biểu", "noCalendarsSynced": "Chưa có lịch nào được đồng bộ.", @@ -467,7 +471,8 @@ "moveToTop": "Chuyển lên đầu", "deleteTask": "Xóa công việc", "newSubtask": "Công việc con mới", - "deleteTaskConfirmation": "Xóa “{title}”?", + "deleteTaskConfirmation": "Xóa “{title}”?", + "deleteAssignedTaskWarning": "Thao tác này cũng xóa công việc gốc trong Google Tài liệu hoặc không gian Chat.", "metadata": "Siêu dữ liệu", "id": "ID", "etag": "ETag", @@ -826,6 +831,8 @@ "removeFromMyCalendars": "Xóa khỏi lịch của tôi", "removeAction": "Xóa", "removeCalendarConfirmation": "Xóa “{title}” khỏi danh sách Google Calendar của bạn? Lịch dùng chung và các sự kiện trong đó sẽ không bị xóa.", + "removeOpenedSharedCalendarConfirmation": "Xóa “{title}” khỏi BusyMax? Lịch và các sự kiện của chủ sở hữu sẽ không bị xóa.", + "attachmentUploadUnresolved": "Tệp có thể đã được tải lên. Làm mới danh sách tệp đính kèm để kiểm tra trước khi tải lên lại.", "calendarCannotRemove": "Không thể xóa hoặc gỡ lịch này khỏi tài khoản.", "calendarPendingChangesPreventRemoval": "Hãy chờ các thay đổi đang chờ của lịch này đồng bộ xong trước khi xóa hoặc gỡ lịch.", "taskListPendingChangesPreventRemoval": "Hãy đồng bộ hóa tài khoản hoặc xử lý các thay đổi bị chặn của danh sách việc cần làm này trong mục Chẩn đoán trước khi xóa hoặc gỡ danh sách.", @@ -1036,7 +1043,10 @@ "searchFilters": "Bộ lọc tìm kiếm", "searchType": "Loại", "searchDate": "Ngày", - "searchAnyDate": "Mọi ngày", + "searchAnyDate": "Mọi ngày (dữ liệu đã tải)", + "eventLink": "Liên kết sự kiện", + "eventLinkOpenFailed": "Không thể mở liên kết.", + "scheduleRangeIncomplete": "Không thể kiểm tra một số sự kiện. Đang hiển thị dữ liệu đã tải.", "searchThisWeek": "Tuần này", "searchCustomRange": "Khoảng tùy chọn", "searchTaskStatus": "Trạng thái công việc", @@ -1051,5 +1061,44 @@ "searchClearText": "Xóa văn bản", "windowMinimize": "Thu nhỏ", "windowMaximize": "Phóng to", - "windowRestore": "Khôi phục" + "windowRestore": "Khôi phục", + "openSharedCalendar": "Mở lịch được chia sẻ", + "calendarOwnerEmail": "Email của chủ sở hữu lịch", + "addCalendarShare": "Thêm quyền truy cập", + "calendarShareFreeBusy": "Chỉ xem bận/rảnh", + "calendarShareLimitedRead": "Đọc thông tin hạn chế", + "calendarShareOwner": "Chủ sở hữu", + "calendarShareRead": "Đọc mọi thông tin", + "calendarShareWrite": "Chỉnh sửa sự kiện", + "calendarShareWriteWithoutPrivate": "Chỉnh sửa không xem thông tin riêng tư", + "eventLabel": "Nhãn sự kiện", + "googleChatAvailable": "Có mặt", + "googleChatDoNotDisturb": "Không làm phiền", + "googleChatStatus": "Trạng thái trò chuyện", + "googleDeclineAll": "Từ chối mọi lời mời trùng lịch", + "googleDeclineInvitations": "Từ chối lời mời trùng lịch", + "googleDeclineMessage": "Lời nhắn từ chối", + "googleDeclineNew": "Từ chối lời mời mới", + "googleDeclineNone": "Không từ chối", + "googleEventType": "Loại sự kiện", + "googleFocusTime": "Thời gian tập trung", + "googleOutOfOffice": "Vắng mặt", + "googleRegularEvent": "Sự kiện thường", + "googleStatusPrimaryOnly": "Sự kiện trạng thái Google yêu cầu lịch Google chính của bạn.", + "googleWorkAtCustomLocation": "Địa điểm tùy chỉnh", + "googleWorkAtHome": "Nhà", + "googleWorkAtOffice": "Văn phòng", + "googleWorkLocationLabel": "Tên địa điểm", + "googleWorkingLocation": "Nơi làm việc", + "loadOutlookCategories": "Tải danh mục Outlook", + "manageCalendarSharing": "Quản lý chia sẻ lịch", + "outlookCategoriesUnavailable": "Không có danh mục Outlook; các mục đã gán vẫn được giữ nguyên.", + "shareRecipientEmail": "Email người nhận", + "shareRole": "Vai trò truy cập", + "sharingPermissionUnavailable": "Không có quyền chia sẻ cho lịch này.", + "sharingRefreshFailed": "Đã thay đổi chia sẻ nhưng không thể làm mới danh sách quyền. Hãy tải lại trước khi thay đổi tiếp.", + "sharingRateLimited": "Việc chia sẻ đang tạm thời bị giới hạn. Hãy thử lại sau khi hết thời gian chờ.", + "attachmentUploadRateLimited": "Việc tải tệp đính kèm lên đang tạm thời bị giới hạn. Hãy thử lại sau khi hết thời gian chờ.", + "unknownEventLabel": "Nhãn không xác định ({id})", + "@unknownEventLabel": {"placeholders": {"id": {"type": "String"}}} } diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index cd068b7f..05978cca 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -171,7 +171,11 @@ }, "newEvent": "新建日程", "refreshCalendar": "刷新日历", - "openInProvider": "在服务中打开", +"openInProvider": "在服务中打开", + "linkedResources": "关联资源", + "noLinkedResources": "没有关联资源", + "attachments": "附件", + "attachmentsNotLoaded": "附件未加载", "hideFromSchedule": "从日程中隐藏", "showInSchedule": "在日程中显示", "noCalendarsSynced": "尚未同步任何日历。", @@ -467,7 +471,8 @@ "moveToTop": "移到顶部", "deleteTask": "删除任务", "newSubtask": "新建子任务", - "deleteTaskConfirmation": "删除“{title}”?", + "deleteTaskConfirmation": "删除“{title}”?", + "deleteAssignedTaskWarning": "这也会删除 Google 文档或 Chat 聊天室中的原始任务。", "metadata": "元数据", "id": "ID", "etag": "ETag", @@ -826,6 +831,8 @@ "removeFromMyCalendars": "从“我的日历”中移除", "removeAction": "移除", "removeCalendarConfirmation": "要从您的 Google 日历列表中移除“{title}”吗?共享日历及其日程不会被删除。", + "removeOpenedSharedCalendarConfirmation": "要从 BusyMax 移除“{title}”吗?所有者的日历和日程不会被删除。", + "attachmentUploadUnresolved": "上传可能已完成。再次上传前,请刷新附件以确认结果。", "calendarCannotRemove": "无法从此账户删除或移除此日历。", "calendarPendingChangesPreventRemoval": "请等待此日历的待处理更改完成同步,然后再删除或移除它。", "taskListPendingChangesPreventRemoval": "删除或移除此列表前,请同步账户,或在“诊断”中解决此任务列表被阻止的更改。", @@ -1036,7 +1043,10 @@ "searchFilters": "搜索筛选条件", "searchType": "类型", "searchDate": "日期", - "searchAnyDate": "任何日期", + "searchAnyDate": "任何日期(已下載資料)", + "eventLink": "活動連結", + "eventLinkOpenFailed": "無法開啟連結。", + "scheduleRangeIncomplete": "部分活動無法確認,正在顯示已下載資料。", "searchThisWeek": "本周", "searchCustomRange": "自定义范围", "searchTaskStatus": "任务状态", @@ -1051,5 +1061,44 @@ "searchClearText": "清除文字", "windowMinimize": "最小化", "windowMaximize": "最大化", - "windowRestore": "还原" + "windowRestore": "还原", + "openSharedCalendar": "打开共享日历", + "calendarOwnerEmail": "日历所有者的电子邮件地址", + "addCalendarShare": "添加访问权限", + "calendarShareFreeBusy": "仅查看忙闲", + "calendarShareLimitedRead": "读取有限详情", + "calendarShareOwner": "所有者", + "calendarShareRead": "读取全部详情", + "calendarShareWrite": "编辑日程", + "calendarShareWriteWithoutPrivate": "编辑但不查看私人详情", + "eventLabel": "日程标签", + "googleChatAvailable": "有空", + "googleChatDoNotDisturb": "勿扰", + "googleChatStatus": "聊天状态", + "googleDeclineAll": "拒绝所有冲突邀请", + "googleDeclineInvitations": "拒绝重叠的邀请", + "googleDeclineMessage": "拒绝消息", + "googleDeclineNew": "拒绝新邀请", + "googleDeclineNone": "不拒绝", + "googleEventType": "日程类型", + "googleFocusTime": "专注时间", + "googleOutOfOffice": "外出", + "googleRegularEvent": "普通日程", + "googleStatusPrimaryOnly": "Google 状态日程需要您的 Google 主日历。", + "googleWorkAtCustomLocation": "自定义地点", + "googleWorkAtHome": "家", + "googleWorkAtOffice": "办公室", + "googleWorkLocationLabel": "地点名称", + "googleWorkingLocation": "工作地点", + "loadOutlookCategories": "加载 Outlook 类别", + "manageCalendarSharing": "管理日历共享", + "outlookCategoriesUnavailable": "Outlook 类别不可用;现有分配会保留。", + "shareRecipientEmail": "接收者邮箱", + "shareRole": "访问角色", + "sharingPermissionUnavailable": "此日历的共享权限不可用。", + "sharingRefreshFailed": "共享更改已成功,但无法刷新权限列表。请在再次更改前重新加载列表。", + "sharingRateLimited": "共享暂时受到限制。请在等待时间结束后重试。", + "attachmentUploadRateLimited": "附件上传暂时受到限制。请在等待时间结束后重试。", + "unknownEventLabel": "未知标签({id})", + "@unknownEventLabel": {"placeholders": {"id": {"type": "String"}}} } diff --git a/lib/l10n/app_zh_Hans.arb b/lib/l10n/app_zh_Hans.arb index 147d9b68..1a31b913 100644 --- a/lib/l10n/app_zh_Hans.arb +++ b/lib/l10n/app_zh_Hans.arb @@ -171,7 +171,11 @@ }, "newEvent": "新建日程", "refreshCalendar": "刷新日历", - "openInProvider": "在服务中打开", +"openInProvider": "在服务中打开", + "linkedResources": "关联资源", + "noLinkedResources": "没有关联资源", + "attachments": "附件", + "attachmentsNotLoaded": "附件未加载", "hideFromSchedule": "从日程中隐藏", "showInSchedule": "在日程中显示", "noCalendarsSynced": "尚未同步任何日历。", @@ -467,7 +471,8 @@ "moveToTop": "移到顶部", "deleteTask": "删除任务", "newSubtask": "新建子任务", - "deleteTaskConfirmation": "删除“{title}”?", + "deleteTaskConfirmation": "删除“{title}”?", + "deleteAssignedTaskWarning": "这也会删除 Google 文档或 Chat 聊天室中的原始任务。", "metadata": "元数据", "id": "ID", "etag": "ETag", @@ -826,6 +831,8 @@ "removeFromMyCalendars": "从“我的日历”中移除", "removeAction": "移除", "removeCalendarConfirmation": "要从您的 Google 日历列表中移除“{title}”吗?共享日历及其日程不会被删除。", + "removeOpenedSharedCalendarConfirmation": "要从 BusyMax 移除“{title}”吗?所有者的日历和日程不会被删除。", + "attachmentUploadUnresolved": "上传可能已完成。再次上传前,请刷新附件以确认结果。", "calendarCannotRemove": "无法从此账户删除或移除此日历。", "calendarPendingChangesPreventRemoval": "请等待此日历的待处理更改完成同步,然后再删除或移除它。", "taskListPendingChangesPreventRemoval": "删除或移除此列表前,请同步账户,或在“诊断”中解决此任务列表被阻止的更改。", @@ -1036,7 +1043,10 @@ "searchFilters": "搜索筛选条件", "searchType": "类型", "searchDate": "日期", - "searchAnyDate": "任何日期", + "searchAnyDate": "任何日期(已下载数据)", + "eventLink": "日程链接", + "eventLinkOpenFailed": "无法打开链接。", + "scheduleRangeIncomplete": "部分日程无法核实,正在显示已下载数据。", "searchThisWeek": "本周", "searchCustomRange": "自定义范围", "searchTaskStatus": "任务状态", @@ -1051,5 +1061,44 @@ "searchClearText": "清除文字", "windowMinimize": "最小化", "windowMaximize": "最大化", - "windowRestore": "还原" + "windowRestore": "还原", + "openSharedCalendar": "打开共享日历", + "calendarOwnerEmail": "日历所有者的电子邮件地址", + "addCalendarShare": "添加访问权限", + "calendarShareFreeBusy": "仅查看忙闲", + "calendarShareLimitedRead": "读取有限详情", + "calendarShareOwner": "所有者", + "calendarShareRead": "读取全部详情", + "calendarShareWrite": "编辑日程", + "calendarShareWriteWithoutPrivate": "编辑但不查看私人详情", + "eventLabel": "日程标签", + "googleChatAvailable": "有空", + "googleChatDoNotDisturb": "勿扰", + "googleChatStatus": "聊天状态", + "googleDeclineAll": "拒绝所有冲突邀请", + "googleDeclineInvitations": "拒绝重叠的邀请", + "googleDeclineMessage": "拒绝消息", + "googleDeclineNew": "拒绝新邀请", + "googleDeclineNone": "不拒绝", + "googleEventType": "日程类型", + "googleFocusTime": "专注时间", + "googleOutOfOffice": "外出", + "googleRegularEvent": "普通日程", + "googleStatusPrimaryOnly": "Google 状态日程需要您的 Google 主日历。", + "googleWorkAtCustomLocation": "自定义地点", + "googleWorkAtHome": "家", + "googleWorkAtOffice": "办公室", + "googleWorkLocationLabel": "地点名称", + "googleWorkingLocation": "工作地点", + "loadOutlookCategories": "加载 Outlook 类别", + "manageCalendarSharing": "管理日历共享", + "outlookCategoriesUnavailable": "Outlook 类别不可用;现有分配会保留。", + "shareRecipientEmail": "接收者邮箱", + "shareRole": "访问角色", + "sharingPermissionUnavailable": "此日历的共享权限不可用。", + "sharingRefreshFailed": "共享更改已成功,但无法刷新权限列表。请在再次更改前重新加载列表。", + "sharingRateLimited": "共享暂时受到限制。请在等待时间结束后重试。", + "attachmentUploadRateLimited": "附件上传暂时受到限制。请在等待时间结束后重试。", + "unknownEventLabel": "未知标签({id})", + "@unknownEventLabel": {"placeholders": {"id": {"type": "String"}}} } diff --git a/lib/l10n/app_zh_Hant.arb b/lib/l10n/app_zh_Hant.arb index ab4b9a02..4b70347b 100644 --- a/lib/l10n/app_zh_Hant.arb +++ b/lib/l10n/app_zh_Hant.arb @@ -171,7 +171,11 @@ }, "newEvent": "新增活動", "refreshCalendar": "重新整理行事曆", - "openInProvider": "在服務中開啟", +"openInProvider": "在服務中開啟", + "linkedResources": "關聯資源", + "noLinkedResources": "沒有關聯資源", + "attachments": "附件", + "attachmentsNotLoaded": "附件尚未載入", "hideFromSchedule": "從行程中隱藏", "showInSchedule": "在行程中顯示", "noCalendarsSynced": "尚未同步任何行事曆。", @@ -467,7 +471,8 @@ "moveToTop": "移至頂端", "deleteTask": "刪除待辦事項", "newSubtask": "新增子待辦事項", - "deleteTaskConfirmation": "要刪除「{title}」嗎?", + "deleteTaskConfirmation": "要刪除「{title}」嗎?", + "deleteAssignedTaskWarning": "這也會刪除 Google 文件或 Chat 聊天室中的原始工作。", "metadata": "中繼資料", "id": "ID", "etag": "ETag", @@ -826,6 +831,8 @@ "removeFromMyCalendars": "從「我的日曆」移除", "removeAction": "移除", "removeCalendarConfirmation": "要從您的 Google 日曆清單移除「{title}」嗎?共用日曆及其活動不會被刪除。", + "removeOpenedSharedCalendarConfirmation": "要從 BusyMax 移除「{title}」嗎?擁有者的日曆及活動不會被刪除。", + "attachmentUploadUnresolved": "上傳可能已完成。再次上傳前,請重新整理附件以確認結果。", "calendarCannotRemove": "無法從此賬戶刪除或移除此日曆。", "calendarPendingChangesPreventRemoval": "請等待此日曆的待處理更改完成同步,然後再刪除或移除它。", "taskListPendingChangesPreventRemoval": "刪除或移除此清單前,請同步帳戶,或在「診斷」中解決此工作清單遭封鎖的變更。", @@ -1036,7 +1043,10 @@ "searchFilters": "搜尋篩選條件", "searchType": "類型", "searchDate": "日期", - "searchAnyDate": "任何日期", + "searchAnyDate": "任何日期(已下載資料)", + "eventLink": "活動連結", + "eventLinkOpenFailed": "無法開啟連結。", + "scheduleRangeIncomplete": "部分活動無法確認,正在顯示已下載資料。", "searchThisWeek": "本週", "searchCustomRange": "自訂範圍", "searchTaskStatus": "工作狀態", @@ -1051,5 +1061,44 @@ "searchClearText": "清除文字", "windowMinimize": "最小化", "windowMaximize": "最大化", - "windowRestore": "還原" + "windowRestore": "還原", + "openSharedCalendar": "開啟共用行事曆", + "calendarOwnerEmail": "行事曆擁有者的電子郵件地址", + "addCalendarShare": "新增存取權", + "calendarShareFreeBusy": "僅顯示忙閒", + "calendarShareLimitedRead": "讀取有限詳情", + "calendarShareOwner": "擁有者", + "calendarShareRead": "讀取全部詳情", + "calendarShareWrite": "編輯行事曆事件", + "calendarShareWriteWithoutPrivate": "編輯但不顯示私人詳情", + "eventLabel": "事件標籤", + "googleChatAvailable": "有空", + "googleChatDoNotDisturb": "零打擾", + "googleChatStatus": "聊天狀態", + "googleDeclineAll": "拒絕所有衝突邀請", + "googleDeclineInvitations": "拒絕重疊邀請", + "googleDeclineMessage": "拒絕訊息", + "googleDeclineNew": "拒絕新邀請", + "googleDeclineNone": "不要拒絕", + "googleEventType": "事件類型", + "googleFocusTime": "專注時間", + "googleOutOfOffice": "不在辦公室", + "googleRegularEvent": "一般事件", + "googleStatusPrimaryOnly": "Google 狀態事件需要您的 Google 主要行事曆。", + "googleWorkAtCustomLocation": "自訂地點", + "googleWorkAtHome": "家中", + "googleWorkAtOffice": "辦公室", + "googleWorkLocationLabel": "地點名稱", + "googleWorkingLocation": "工作地點", + "loadOutlookCategories": "載入 Outlook 類別", + "manageCalendarSharing": "管理行事曆共用", + "outlookCategoriesUnavailable": "Outlook 類別無法使用;現有指派會保留。", + "shareRecipientEmail": "收件者電子郵件", + "shareRole": "存取角色", + "sharingPermissionUnavailable": "此行事曆的共用權限無法使用。", + "sharingRefreshFailed": "共用設定已變更,但無法重新整理權限清單。再次變更前請重新載入清單。", + "sharingRateLimited": "共用暫時受到限制。請在等待時間結束後重試。", + "attachmentUploadRateLimited": "附件上傳暫時受到限制。請在等待時間結束後重試。", + "unknownEventLabel": "未知標籤({id})", + "@unknownEventLabel": {"placeholders": {"id": {"type": "String"}}} } diff --git a/lib/l10n/generated/app_localizations.dart b/lib/l10n/generated/app_localizations.dart index 8fea3128..83faf108 100644 --- a/lib/l10n/generated/app_localizations.dart +++ b/lib/l10n/generated/app_localizations.dart @@ -1014,6 +1014,30 @@ abstract class AppLocalizations { /// **'Open in provider'** String get openInProvider; + /// No description provided for @linkedResources. + /// + /// In en, this message translates to: + /// **'Linked resources'** + String get linkedResources; + + /// No description provided for @noLinkedResources. + /// + /// In en, this message translates to: + /// **'No linked resources'** + String get noLinkedResources; + + /// No description provided for @attachments. + /// + /// In en, this message translates to: + /// **'Attachments'** + String get attachments; + + /// No description provided for @attachmentsNotLoaded. + /// + /// In en, this message translates to: + /// **'Attachments not loaded'** + String get attachmentsNotLoaded; + /// No description provided for @hideFromSchedule. /// /// In en, this message translates to: @@ -3420,6 +3444,12 @@ abstract class AppLocalizations { /// **'Delete \"{title}\"?'** String deleteTaskConfirmation(String title); + /// No description provided for @deleteAssignedTaskWarning. + /// + /// In en, this message translates to: + /// **'This also deletes the original task in Google Docs or Chat Spaces.'** + String get deleteAssignedTaskWarning; + /// No description provided for @metadata. /// /// In en, this message translates to: @@ -4488,6 +4518,18 @@ abstract class AppLocalizations { /// **'Remove'** String get removeAction; + /// No description provided for @removeOpenedSharedCalendarConfirmation. + /// + /// In en, this message translates to: + /// **'Remove \"{title}\" from BusyMax? The owner\'s calendar and events will not be deleted.'** + String removeOpenedSharedCalendarConfirmation(String title); + + /// No description provided for @attachmentUploadUnresolved. + /// + /// In en, this message translates to: + /// **'The upload may have completed. Refresh attachments to resolve its outcome before uploading again.'** + String get attachmentUploadUnresolved; + /// No description provided for @removeCalendarConfirmation. /// /// In en, this message translates to: @@ -4949,9 +4991,27 @@ abstract class AppLocalizations { /// No description provided for @searchAnyDate. /// /// In en, this message translates to: - /// **'Any date'** + /// **'Any date (downloaded data)'** String get searchAnyDate; + /// No description provided for @eventLink. + /// + /// In en, this message translates to: + /// **'Event link'** + String get eventLink; + + /// No description provided for @eventLinkOpenFailed. + /// + /// In en, this message translates to: + /// **'Could not open the link.'** + String get eventLinkOpenFailed; + + /// No description provided for @scheduleRangeIncomplete. + /// + /// In en, this message translates to: + /// **'Some events could not be checked. Showing downloaded data.'** + String get scheduleRangeIncomplete; + /// No description provided for @searchThisWeek. /// /// In en, this message translates to: @@ -5023,6 +5083,234 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Clear text'** String get searchClearText; + + /// No description provided for @openSharedCalendar. + /// + /// In en, this message translates to: + /// **'Open shared calendar'** + String get openSharedCalendar; + + /// No description provided for @manageCalendarSharing. + /// + /// In en, this message translates to: + /// **'Manage calendar sharing'** + String get manageCalendarSharing; + + /// No description provided for @shareRecipientEmail. + /// + /// In en, this message translates to: + /// **'Recipient email'** + String get shareRecipientEmail; + + /// No description provided for @shareRole. + /// + /// In en, this message translates to: + /// **'Access role'** + String get shareRole; + + /// No description provided for @addCalendarShare. + /// + /// In en, this message translates to: + /// **'Add access'** + String get addCalendarShare; + + /// No description provided for @sharingRefreshFailed. + /// + /// In en, this message translates to: + /// **'The sharing change succeeded, but the permission list could not be refreshed. Retry the list before making another change.'** + String get sharingRefreshFailed; + + /// No description provided for @sharingRateLimited. + /// + /// In en, this message translates to: + /// **'Sharing is temporarily rate-limited. Try again when the wait ends.'** + String get sharingRateLimited; + + /// No description provided for @attachmentUploadRateLimited. + /// + /// In en, this message translates to: + /// **'Attachment upload is temporarily rate-limited. Try again when the wait ends.'** + String get attachmentUploadRateLimited; + + /// No description provided for @sharingPermissionUnavailable. + /// + /// In en, this message translates to: + /// **'Sharing permissions are unavailable for this calendar.'** + String get sharingPermissionUnavailable; + + /// No description provided for @calendarShareFreeBusy. + /// + /// In en, this message translates to: + /// **'Free/busy only'** + String get calendarShareFreeBusy; + + /// No description provided for @calendarShareLimitedRead. + /// + /// In en, this message translates to: + /// **'Read limited details'** + String get calendarShareLimitedRead; + + /// No description provided for @calendarShareRead. + /// + /// In en, this message translates to: + /// **'Read all details'** + String get calendarShareRead; + + /// No description provided for @calendarShareWrite. + /// + /// In en, this message translates to: + /// **'Edit events'** + String get calendarShareWrite; + + /// No description provided for @calendarShareWriteWithoutPrivate. + /// + /// In en, this message translates to: + /// **'Edit without private details'** + String get calendarShareWriteWithoutPrivate; + + /// No description provided for @calendarShareOwner. + /// + /// In en, this message translates to: + /// **'Owner'** + String get calendarShareOwner; + + /// No description provided for @eventLabel. + /// + /// In en, this message translates to: + /// **'Event label'** + String get eventLabel; + + /// No description provided for @loadOutlookCategories. + /// + /// In en, this message translates to: + /// **'Load Outlook categories'** + String get loadOutlookCategories; + + /// No description provided for @outlookCategoriesUnavailable. + /// + /// In en, this message translates to: + /// **'Outlook categories are unavailable; existing assignments are preserved.'** + String get outlookCategoriesUnavailable; + + /// No description provided for @googleEventType. + /// + /// In en, this message translates to: + /// **'Event type'** + String get googleEventType; + + /// No description provided for @googleRegularEvent. + /// + /// In en, this message translates to: + /// **'Regular event'** + String get googleRegularEvent; + + /// No description provided for @googleFocusTime. + /// + /// In en, this message translates to: + /// **'Focus time'** + String get googleFocusTime; + + /// No description provided for @googleOutOfOffice. + /// + /// In en, this message translates to: + /// **'Out of office'** + String get googleOutOfOffice; + + /// No description provided for @googleWorkingLocation. + /// + /// In en, this message translates to: + /// **'Working location'** + String get googleWorkingLocation; + + /// No description provided for @googleDeclineInvitations. + /// + /// In en, this message translates to: + /// **'Decline overlapping invitations'** + String get googleDeclineInvitations; + + /// No description provided for @googleDeclineNone. + /// + /// In en, this message translates to: + /// **'Do not decline'** + String get googleDeclineNone; + + /// No description provided for @googleDeclineNew. + /// + /// In en, this message translates to: + /// **'Decline new invitations'** + String get googleDeclineNew; + + /// No description provided for @googleDeclineAll. + /// + /// In en, this message translates to: + /// **'Decline all conflicting invitations'** + String get googleDeclineAll; + + /// No description provided for @googleDeclineMessage. + /// + /// In en, this message translates to: + /// **'Decline message'** + String get googleDeclineMessage; + + /// No description provided for @googleChatStatus. + /// + /// In en, this message translates to: + /// **'Chat status'** + String get googleChatStatus; + + /// No description provided for @googleChatAvailable. + /// + /// In en, this message translates to: + /// **'Available'** + String get googleChatAvailable; + + /// No description provided for @googleChatDoNotDisturb. + /// + /// In en, this message translates to: + /// **'Do not disturb'** + String get googleChatDoNotDisturb; + + /// No description provided for @googleWorkAtHome. + /// + /// In en, this message translates to: + /// **'Home'** + String get googleWorkAtHome; + + /// No description provided for @googleWorkAtOffice. + /// + /// In en, this message translates to: + /// **'Office'** + String get googleWorkAtOffice; + + /// No description provided for @googleWorkAtCustomLocation. + /// + /// In en, this message translates to: + /// **'Custom location'** + String get googleWorkAtCustomLocation; + + /// No description provided for @googleWorkLocationLabel. + /// + /// In en, this message translates to: + /// **'Location label'** + String get googleWorkLocationLabel; + + /// No description provided for @googleStatusPrimaryOnly. + /// + /// In en, this message translates to: + /// **'Google status events require your primary Google calendar.'** + String get googleStatusPrimaryOnly; + + /// No description provided for @unknownEventLabel. + /// + /// In en, this message translates to: + /// **'Unknown label ({id})'** + String unknownEventLabel(String id); + + /// No description provided for @calendarOwnerEmail. + /// + /// In en, this message translates to: + /// **'Calendar owner email'** + String get calendarOwnerEmail; } class _AppLocalizationsDelegate diff --git a/lib/l10n/generated/app_localizations_ar.dart b/lib/l10n/generated/app_localizations_ar.dart index 26e99347..754c8cfa 100644 --- a/lib/l10n/generated/app_localizations_ar.dart +++ b/lib/l10n/generated/app_localizations_ar.dart @@ -540,6 +540,18 @@ class AppLocalizationsAr extends AppLocalizations { @override String get openInProvider => 'فتح في الخدمة'; + @override + String get linkedResources => 'الموارد المرتبطة'; + + @override + String get noLinkedResources => 'لا توجد موارد مرتبطة'; + + @override + String get attachments => 'المرفقات'; + + @override + String get attachmentsNotLoaded => 'لم يتم تحميل المرفقات'; + @override String get hideFromSchedule => 'إخفاء من الجدول'; @@ -2065,6 +2077,10 @@ class AppLocalizationsAr extends AppLocalizations { return 'حذف «⁨⁨$title⁩⁩»؟'; } + @override + String get deleteAssignedTaskWarning => + 'سيؤدي هذا أيضًا إلى حذف المهمة الأصلية في مستندات Google أو مساحات Chat.'; + @override String get metadata => 'البيانات الوصفية'; @@ -2694,6 +2710,15 @@ class AppLocalizationsAr extends AppLocalizations { @override String get removeAction => 'إزالة'; + @override + String removeOpenedSharedCalendarConfirmation(String title) { + return 'هل تريد إزالة \"⁨$title⁩\" من BusyMax؟ لن يُحذف تقويم المالك أو أحداثه.'; + } + + @override + String get attachmentUploadUnresolved => + 'ربما اكتمل رفع المرفق. حدّث المرفقات للتحقق من النتيجة قبل الرفع مرة أخرى.'; + @override String removeCalendarConfirmation(String title) { return 'هل تريد إزالة \"⁨$title⁩\" من قائمة تقويم Google؟ لن يتم حذف التقويم المشترك أو أحداثه.'; @@ -3031,7 +3056,17 @@ class AppLocalizationsAr extends AppLocalizations { String get searchDate => 'التاريخ'; @override - String get searchAnyDate => 'أي تاريخ'; + String get searchAnyDate => 'أي تاريخ (البيانات المحمّلة)'; + + @override + String get eventLink => 'رابط الحدث'; + + @override + String get eventLinkOpenFailed => 'تعذّر فتح الرابط.'; + + @override + String get scheduleRangeIncomplete => + 'تعذّر التحقق من بعض الأحداث. تُعرض البيانات المحمّلة.'; @override String get searchThisWeek => 'هذا الأسبوع'; @@ -3068,4 +3103,126 @@ class AppLocalizationsAr extends AppLocalizations { @override String get searchClearText => 'مسح النص'; + + @override + String get openSharedCalendar => 'فتح تقويم مشترك'; + + @override + String get manageCalendarSharing => 'إدارة مشاركة التقويم'; + + @override + String get shareRecipientEmail => 'البريد الإلكتروني للمستلم'; + + @override + String get shareRole => 'دور الوصول'; + + @override + String get addCalendarShare => 'إضافة وصول'; + + @override + String get sharingRefreshFailed => + 'نجح تغيير المشاركة، لكن تعذر تحديث قائمة الأذونات. أعد تحميل القائمة قبل إجراء تغيير آخر.'; + + @override + String get sharingRateLimited => + 'المشاركة مقيّدة مؤقتًا. حاول مرة أخرى بعد انتهاء فترة الانتظار.'; + + @override + String get attachmentUploadRateLimited => + 'تحميل المرفقات مقيّد مؤقتًا. حاول مرة أخرى بعد انتهاء فترة الانتظار.'; + + @override + String get sharingPermissionUnavailable => + 'أذونات المشاركة غير متاحة لهذا التقويم.'; + + @override + String get calendarShareFreeBusy => 'التوفر فقط'; + + @override + String get calendarShareLimitedRead => 'قراءة تفاصيل محدودة'; + + @override + String get calendarShareRead => 'قراءة جميع التفاصيل'; + + @override + String get calendarShareWrite => 'تعديل الأحداث'; + + @override + String get calendarShareWriteWithoutPrivate => 'تعديل دون التفاصيل الخاصة'; + + @override + String get calendarShareOwner => 'المالك'; + + @override + String get eventLabel => 'تسمية الحدث'; + + @override + String get loadOutlookCategories => 'تحميل فئات Outlook'; + + @override + String get outlookCategoriesUnavailable => + 'فئات Outlook غير متاحة؛ التعيينات الحالية محفوظة.'; + + @override + String get googleEventType => 'نوع الحدث'; + + @override + String get googleRegularEvent => 'حدث عادي'; + + @override + String get googleFocusTime => 'وقت التركيز'; + + @override + String get googleOutOfOffice => 'خارج المكتب'; + + @override + String get googleWorkingLocation => 'موقع العمل'; + + @override + String get googleDeclineInvitations => 'رفض الدعوات المتداخلة'; + + @override + String get googleDeclineNone => 'عدم الرفض'; + + @override + String get googleDeclineNew => 'رفض الدعوات الجديدة'; + + @override + String get googleDeclineAll => 'رفض جميع الدعوات المتعارضة'; + + @override + String get googleDeclineMessage => 'رسالة الرفض'; + + @override + String get googleChatStatus => 'حالة المحادثة'; + + @override + String get googleChatAvailable => 'متاح'; + + @override + String get googleChatDoNotDisturb => 'عدم الإزعاج'; + + @override + String get googleWorkAtHome => 'المنزل'; + + @override + String get googleWorkAtOffice => 'المكتب'; + + @override + String get googleWorkAtCustomLocation => 'موقع مخصص'; + + @override + String get googleWorkLocationLabel => 'وصف الموقع'; + + @override + String get googleStatusPrimaryOnly => + 'تتطلب أحداث الحالة تقويم Google الأساسي.'; + + @override + String unknownEventLabel(String id) { + return 'تسمية غير معروفة (⁨$id⁩)'; + } + + @override + String get calendarOwnerEmail => 'البريد الإلكتروني لمالك التقويم'; } diff --git a/lib/l10n/generated/app_localizations_de.dart b/lib/l10n/generated/app_localizations_de.dart index 758cfbdb..84cf5666 100644 --- a/lib/l10n/generated/app_localizations_de.dart +++ b/lib/l10n/generated/app_localizations_de.dart @@ -546,6 +546,18 @@ class AppLocalizationsDe extends AppLocalizations { @override String get openInProvider => 'Beim Anbieter öffnen'; + @override + String get linkedResources => 'Verknüpfte Ressourcen'; + + @override + String get noLinkedResources => 'Keine verknüpften Ressourcen'; + + @override + String get attachments => 'Anhänge'; + + @override + String get attachmentsNotLoaded => 'Anhänge nicht geladen'; + @override String get hideFromSchedule => 'Im Zeitplan ausblenden'; @@ -1999,6 +2011,10 @@ class AppLocalizationsDe extends AppLocalizations { return '\"$title\" löschen?'; } + @override + String get deleteAssignedTaskWarning => + 'Dadurch wird auch die ursprüngliche Aufgabe in Google Docs oder Chat-Bereichen gelöscht.'; + @override String get metadata => 'Metadaten'; @@ -2628,6 +2644,15 @@ class AppLocalizationsDe extends AppLocalizations { @override String get removeAction => 'Entfernen'; + @override + String removeOpenedSharedCalendarConfirmation(String title) { + return '„$title“ aus BusyMax entfernen? Der Kalender und die Termine des Besitzers werden nicht gelöscht.'; + } + + @override + String get attachmentUploadUnresolved => + 'Der Upload wurde möglicherweise abgeschlossen. Aktualisieren Sie die Anhänge, bevor Sie erneut hochladen.'; + @override String removeCalendarConfirmation(String title) { return '„$title“ aus Ihrer Google-Kalenderliste entfernen? Der freigegebene Kalender und seine Termine werden nicht gelöscht.'; @@ -2967,7 +2992,17 @@ class AppLocalizationsDe extends AppLocalizations { String get searchDate => 'Datum'; @override - String get searchAnyDate => 'Beliebiges Datum'; + String get searchAnyDate => 'Beliebiges Datum (heruntergeladene Daten)'; + + @override + String get eventLink => 'Terminlink'; + + @override + String get eventLinkOpenFailed => 'Der Link konnte nicht geöffnet werden.'; + + @override + String get scheduleRangeIncomplete => + 'Einige Termine konnten nicht geprüft werden. Heruntergeladene Daten werden angezeigt.'; @override String get searchThisWeek => 'Diese Woche'; @@ -3004,4 +3039,127 @@ class AppLocalizationsDe extends AppLocalizations { @override String get searchClearText => 'Text löschen'; + + @override + String get openSharedCalendar => 'Freigegebenen Kalender öffnen'; + + @override + String get manageCalendarSharing => 'Kalenderfreigabe verwalten'; + + @override + String get shareRecipientEmail => 'E-Mail-Adresse des Empfängers'; + + @override + String get shareRole => 'Zugriffsrolle'; + + @override + String get addCalendarShare => 'Zugriff hinzufügen'; + + @override + String get sharingRefreshFailed => + 'Die Freigabe wurde geändert, aber die Berechtigungsliste konnte nicht aktualisiert werden. Laden Sie sie vor einer weiteren Änderung neu.'; + + @override + String get sharingRateLimited => + 'Die Freigabe ist vorübergehend begrenzt. Versuchen Sie es nach der Wartezeit erneut.'; + + @override + String get attachmentUploadRateLimited => + 'Das Hochladen von Anhängen ist vorübergehend begrenzt. Versuchen Sie es nach der Wartezeit erneut.'; + + @override + String get sharingPermissionUnavailable => + 'Freigabeberechtigungen sind für diesen Kalender nicht verfügbar.'; + + @override + String get calendarShareFreeBusy => 'Nur Frei/Gebucht'; + + @override + String get calendarShareLimitedRead => 'Eingeschränkte Details lesen'; + + @override + String get calendarShareRead => 'Alle Details lesen'; + + @override + String get calendarShareWrite => 'Termine bearbeiten'; + + @override + String get calendarShareWriteWithoutPrivate => + 'Ohne private Details bearbeiten'; + + @override + String get calendarShareOwner => 'Besitzer'; + + @override + String get eventLabel => 'Terminlabel'; + + @override + String get loadOutlookCategories => 'Outlook-Kategorien laden'; + + @override + String get outlookCategoriesUnavailable => + 'Outlook-Kategorien sind nicht verfügbar; bestehende Zuordnungen bleiben erhalten.'; + + @override + String get googleEventType => 'Termintyp'; + + @override + String get googleRegularEvent => 'Normaler Termin'; + + @override + String get googleFocusTime => 'Fokuszeit'; + + @override + String get googleOutOfOffice => 'Abwesend'; + + @override + String get googleWorkingLocation => 'Arbeitsort'; + + @override + String get googleDeclineInvitations => 'Überschneidende Einladungen ablehnen'; + + @override + String get googleDeclineNone => 'Nicht ablehnen'; + + @override + String get googleDeclineNew => 'Neue Einladungen ablehnen'; + + @override + String get googleDeclineAll => 'Alle kollidierenden Einladungen ablehnen'; + + @override + String get googleDeclineMessage => 'Ablehnungsnachricht'; + + @override + String get googleChatStatus => 'Chatstatus'; + + @override + String get googleChatAvailable => 'Verfügbar'; + + @override + String get googleChatDoNotDisturb => 'Nicht stören'; + + @override + String get googleWorkAtHome => 'Zu Hause'; + + @override + String get googleWorkAtOffice => 'Im Büro'; + + @override + String get googleWorkAtCustomLocation => 'Eigener Ort'; + + @override + String get googleWorkLocationLabel => 'Ortsbezeichnung'; + + @override + String get googleStatusPrimaryOnly => + 'Google-Statusereignisse benötigen Ihren primären Google-Kalender.'; + + @override + String unknownEventLabel(String id) { + return 'Unbekanntes Label ($id)'; + } + + @override + String get calendarOwnerEmail => 'E-Mail-Adresse des Kalenderbesitzers'; } diff --git a/lib/l10n/generated/app_localizations_en.dart b/lib/l10n/generated/app_localizations_en.dart index a3c49c42..ca14f0c0 100644 --- a/lib/l10n/generated/app_localizations_en.dart +++ b/lib/l10n/generated/app_localizations_en.dart @@ -548,6 +548,18 @@ class AppLocalizationsEn extends AppLocalizations { @override String get openInProvider => 'Open in provider'; + @override + String get linkedResources => 'Linked resources'; + + @override + String get noLinkedResources => 'No linked resources'; + + @override + String get attachments => 'Attachments'; + + @override + String get attachmentsNotLoaded => 'Attachments not loaded'; + @override String get hideFromSchedule => 'Hide from schedule'; @@ -2018,6 +2030,10 @@ class AppLocalizationsEn extends AppLocalizations { return 'Delete \"$title\"?'; } + @override + String get deleteAssignedTaskWarning => + 'This also deletes the original task in Google Docs or Chat Spaces.'; + @override String get metadata => 'Metadata'; @@ -2642,6 +2658,15 @@ class AppLocalizationsEn extends AppLocalizations { @override String get removeAction => 'Remove'; + @override + String removeOpenedSharedCalendarConfirmation(String title) { + return 'Remove \"$title\" from BusyMax? The owner\'s calendar and events will not be deleted.'; + } + + @override + String get attachmentUploadUnresolved => + 'The upload may have completed. Refresh attachments to resolve its outcome before uploading again.'; + @override String removeCalendarConfirmation(String title) { return 'Remove \"$title\" from your Google Calendar list? The shared calendar and its events will not be deleted.'; @@ -2999,7 +3024,17 @@ class AppLocalizationsEn extends AppLocalizations { String get searchDate => 'Date'; @override - String get searchAnyDate => 'Any date'; + String get searchAnyDate => 'Any date (downloaded data)'; + + @override + String get eventLink => 'Event link'; + + @override + String get eventLinkOpenFailed => 'Could not open the link.'; + + @override + String get scheduleRangeIncomplete => + 'Some events could not be checked. Showing downloaded data.'; @override String get searchThisWeek => 'This week'; @@ -3036,4 +3071,126 @@ class AppLocalizationsEn extends AppLocalizations { @override String get searchClearText => 'Clear text'; + + @override + String get openSharedCalendar => 'Open shared calendar'; + + @override + String get manageCalendarSharing => 'Manage calendar sharing'; + + @override + String get shareRecipientEmail => 'Recipient email'; + + @override + String get shareRole => 'Access role'; + + @override + String get addCalendarShare => 'Add access'; + + @override + String get sharingRefreshFailed => + 'The sharing change succeeded, but the permission list could not be refreshed. Retry the list before making another change.'; + + @override + String get sharingRateLimited => + 'Sharing is temporarily rate-limited. Try again when the wait ends.'; + + @override + String get attachmentUploadRateLimited => + 'Attachment upload is temporarily rate-limited. Try again when the wait ends.'; + + @override + String get sharingPermissionUnavailable => + 'Sharing permissions are unavailable for this calendar.'; + + @override + String get calendarShareFreeBusy => 'Free/busy only'; + + @override + String get calendarShareLimitedRead => 'Read limited details'; + + @override + String get calendarShareRead => 'Read all details'; + + @override + String get calendarShareWrite => 'Edit events'; + + @override + String get calendarShareWriteWithoutPrivate => 'Edit without private details'; + + @override + String get calendarShareOwner => 'Owner'; + + @override + String get eventLabel => 'Event label'; + + @override + String get loadOutlookCategories => 'Load Outlook categories'; + + @override + String get outlookCategoriesUnavailable => + 'Outlook categories are unavailable; existing assignments are preserved.'; + + @override + String get googleEventType => 'Event type'; + + @override + String get googleRegularEvent => 'Regular event'; + + @override + String get googleFocusTime => 'Focus time'; + + @override + String get googleOutOfOffice => 'Out of office'; + + @override + String get googleWorkingLocation => 'Working location'; + + @override + String get googleDeclineInvitations => 'Decline overlapping invitations'; + + @override + String get googleDeclineNone => 'Do not decline'; + + @override + String get googleDeclineNew => 'Decline new invitations'; + + @override + String get googleDeclineAll => 'Decline all conflicting invitations'; + + @override + String get googleDeclineMessage => 'Decline message'; + + @override + String get googleChatStatus => 'Chat status'; + + @override + String get googleChatAvailable => 'Available'; + + @override + String get googleChatDoNotDisturb => 'Do not disturb'; + + @override + String get googleWorkAtHome => 'Home'; + + @override + String get googleWorkAtOffice => 'Office'; + + @override + String get googleWorkAtCustomLocation => 'Custom location'; + + @override + String get googleWorkLocationLabel => 'Location label'; + + @override + String get googleStatusPrimaryOnly => + 'Google status events require your primary Google calendar.'; + + @override + String unknownEventLabel(String id) { + return 'Unknown label ($id)'; + } + + @override + String get calendarOwnerEmail => 'Calendar owner email'; } diff --git a/lib/l10n/generated/app_localizations_es.dart b/lib/l10n/generated/app_localizations_es.dart index 686c07da..8bfaa2b5 100644 --- a/lib/l10n/generated/app_localizations_es.dart +++ b/lib/l10n/generated/app_localizations_es.dart @@ -550,6 +550,18 @@ class AppLocalizationsEs extends AppLocalizations { @override String get openInProvider => 'Abrir en el proveedor'; + @override + String get linkedResources => 'Recursos vinculados'; + + @override + String get noLinkedResources => 'No hay recursos vinculados'; + + @override + String get attachments => 'Archivos adjuntos'; + + @override + String get attachmentsNotLoaded => 'Archivos adjuntos no cargados'; + @override String get hideFromSchedule => 'Ocultar de la agenda'; @@ -2004,6 +2016,10 @@ class AppLocalizationsEs extends AppLocalizations { return '¿Eliminar \"$title\"?'; } + @override + String get deleteAssignedTaskWarning => + 'Esto también elimina la tarea original de Documentos de Google o los espacios de Chat.'; + @override String get metadata => 'Metadatos'; @@ -2632,6 +2648,15 @@ class AppLocalizationsEs extends AppLocalizations { @override String get removeAction => 'Quitar'; + @override + String removeOpenedSharedCalendarConfirmation(String title) { + return '¿Quitar \"$title\" de BusyMax? No se eliminarán el calendario ni los eventos de su propietario.'; + } + + @override + String get attachmentUploadUnresolved => + 'Es posible que la carga se haya completado. Actualiza los adjuntos para comprobarlo antes de volver a subir el archivo.'; + @override String removeCalendarConfirmation(String title) { return '¿Quitar \"$title\" de tu lista de Google Calendar? No se eliminarán el calendario compartido ni sus eventos.'; @@ -2971,7 +2996,17 @@ class AppLocalizationsEs extends AppLocalizations { String get searchDate => 'Fecha'; @override - String get searchAnyDate => 'Cualquier fecha'; + String get searchAnyDate => 'Cualquier fecha (datos descargados)'; + + @override + String get eventLink => 'Enlace del evento'; + + @override + String get eventLinkOpenFailed => 'No se pudo abrir el enlace.'; + + @override + String get scheduleRangeIncomplete => + 'No se pudieron comprobar algunos eventos. Se muestran los datos descargados.'; @override String get searchThisWeek => 'Esta semana'; @@ -3008,4 +3043,127 @@ class AppLocalizationsEs extends AppLocalizations { @override String get searchClearText => 'Borrar texto'; + + @override + String get openSharedCalendar => 'Abrir calendario compartido'; + + @override + String get manageCalendarSharing => + 'Administrar uso compartido del calendario'; + + @override + String get shareRecipientEmail => 'Correo del destinatario'; + + @override + String get shareRole => 'Rol de acceso'; + + @override + String get addCalendarShare => 'Añadir acceso'; + + @override + String get sharingRefreshFailed => + 'Se cambió el acceso, pero no se pudo actualizar la lista de permisos. Vuelve a cargarla antes de otro cambio.'; + + @override + String get sharingRateLimited => + 'El uso compartido está limitado temporalmente. Inténtalo de nuevo cuando termine la espera.'; + + @override + String get attachmentUploadRateLimited => + 'La carga de archivos adjuntos está limitada temporalmente. Inténtalo de nuevo cuando termine la espera.'; + + @override + String get sharingPermissionUnavailable => + 'Los permisos para compartir no están disponibles para este calendario.'; + + @override + String get calendarShareFreeBusy => 'Solo libre/ocupado'; + + @override + String get calendarShareLimitedRead => 'Leer detalles limitados'; + + @override + String get calendarShareRead => 'Leer todos los detalles'; + + @override + String get calendarShareWrite => 'Editar eventos'; + + @override + String get calendarShareWriteWithoutPrivate => 'Editar sin detalles privados'; + + @override + String get calendarShareOwner => 'Propietario'; + + @override + String get eventLabel => 'Etiqueta del evento'; + + @override + String get loadOutlookCategories => 'Cargar categorías de Outlook'; + + @override + String get outlookCategoriesUnavailable => + 'Las categorías de Outlook no están disponibles; se conservan las asignaciones actuales.'; + + @override + String get googleEventType => 'Tipo de evento'; + + @override + String get googleRegularEvent => 'Evento normal'; + + @override + String get googleFocusTime => 'Tiempo de concentración'; + + @override + String get googleOutOfOffice => 'Fuera de la oficina'; + + @override + String get googleWorkingLocation => 'Lugar de trabajo'; + + @override + String get googleDeclineInvitations => 'Rechazar invitaciones superpuestas'; + + @override + String get googleDeclineNone => 'No rechazar'; + + @override + String get googleDeclineNew => 'Rechazar invitaciones nuevas'; + + @override + String get googleDeclineAll => 'Rechazar todas las invitaciones en conflicto'; + + @override + String get googleDeclineMessage => 'Mensaje de rechazo'; + + @override + String get googleChatStatus => 'Estado del chat'; + + @override + String get googleChatAvailable => 'Disponible'; + + @override + String get googleChatDoNotDisturb => 'No molestar'; + + @override + String get googleWorkAtHome => 'Casa'; + + @override + String get googleWorkAtOffice => 'Oficina'; + + @override + String get googleWorkAtCustomLocation => 'Ubicación personalizada'; + + @override + String get googleWorkLocationLabel => 'Etiqueta de ubicación'; + + @override + String get googleStatusPrimaryOnly => + 'Los eventos de estado de Google requieren tu calendario principal de Google.'; + + @override + String unknownEventLabel(String id) { + return 'Etiqueta desconocida ($id)'; + } + + @override + String get calendarOwnerEmail => 'Correo del propietario del calendario'; } diff --git a/lib/l10n/generated/app_localizations_et.dart b/lib/l10n/generated/app_localizations_et.dart index c3719749..a9f22279 100644 --- a/lib/l10n/generated/app_localizations_et.dart +++ b/lib/l10n/generated/app_localizations_et.dart @@ -542,6 +542,18 @@ class AppLocalizationsEt extends AppLocalizations { @override String get openInProvider => 'Ava teenuses'; + @override + String get linkedResources => 'Seotud ressursid'; + + @override + String get noLinkedResources => 'Seotud ressursse pole'; + + @override + String get attachments => 'Manused'; + + @override + String get attachmentsNotLoaded => 'Manuseid ei laaditud'; + @override String get hideFromSchedule => 'Peida ajakavast'; @@ -1984,6 +1996,10 @@ class AppLocalizationsEt extends AppLocalizations { return 'Kas kustutada „$title”?'; } + @override + String get deleteAssignedTaskWarning => + 'See kustutab ka algse ülesande Google Docsis või Chati ruumides.'; + @override String get metadata => 'Metaandmed'; @@ -2594,6 +2610,15 @@ class AppLocalizationsEt extends AppLocalizations { @override String get removeAction => 'Eemalda'; + @override + String removeOpenedSharedCalendarConfirmation(String title) { + return 'Kas eemaldada „$title” BusyMaxist? Omaniku kalendrit ega sündmusi ei kustutata.'; + } + + @override + String get attachmentUploadUnresolved => + 'Üleslaadimine võis õnnestuda. Enne uut üleslaadimist värskendage manuseid ja kontrollige tulemust.'; + @override String removeCalendarConfirmation(String title) { return 'Kas eemaldada „$title” teie Google’i kalendrite loendist? Jagatud kalendrit ega selle sündmusi ei kustutata.'; @@ -2946,7 +2971,17 @@ class AppLocalizationsEt extends AppLocalizations { String get searchDate => 'Kuupäev'; @override - String get searchAnyDate => 'Kõik kuupäevad'; + String get searchAnyDate => 'Kõik kuupäevad (allalaaditud andmed)'; + + @override + String get eventLink => 'Sündmuse link'; + + @override + String get eventLinkOpenFailed => 'Linki ei õnnestunud avada.'; + + @override + String get scheduleRangeIncomplete => + 'Mõnda sündmust ei saanud kontrollida. Kuvatakse allalaaditud andmed.'; @override String get searchThisWeek => 'See nädal'; @@ -2983,4 +3018,127 @@ class AppLocalizationsEt extends AppLocalizations { @override String get searchClearText => 'Tühjenda tekst'; + + @override + String get openSharedCalendar => 'Ava jagatud kalender'; + + @override + String get manageCalendarSharing => 'Halda kalendri jagamist'; + + @override + String get shareRecipientEmail => 'Saaja e-posti aadress'; + + @override + String get shareRole => 'Juurdepääsu roll'; + + @override + String get addCalendarShare => 'Lisa juurdepääs'; + + @override + String get sharingRefreshFailed => + 'Jagamine muudeti, kuid õiguste loendit ei saanud värskendada. Värskenda loendit enne järgmist muudatust.'; + + @override + String get sharingRateLimited => + 'Jagamine on ajutiselt piiratud. Proovi pärast ooteaja lõppu uuesti.'; + + @override + String get attachmentUploadRateLimited => + 'Manuste üleslaadimine on ajutiselt piiratud. Proovi pärast ooteaja lõppu uuesti.'; + + @override + String get sharingPermissionUnavailable => + 'Selle kalendri jagamisõigused pole saadaval.'; + + @override + String get calendarShareFreeBusy => 'Ainult vaba/hõivatud'; + + @override + String get calendarShareLimitedRead => 'Loe piiratud üksikasju'; + + @override + String get calendarShareRead => 'Loe kõiki üksikasju'; + + @override + String get calendarShareWrite => 'Muuda sündmusi'; + + @override + String get calendarShareWriteWithoutPrivate => + 'Muuda ilma privaatsete üksikasjadeta'; + + @override + String get calendarShareOwner => 'Omanik'; + + @override + String get eventLabel => 'Sündmuse silt'; + + @override + String get loadOutlookCategories => 'Laadi Outlooki kategooriad'; + + @override + String get outlookCategoriesUnavailable => + 'Outlooki kategooriad pole saadaval; senised määrangud säilivad.'; + + @override + String get googleEventType => 'Sündmuse tüüp'; + + @override + String get googleRegularEvent => 'Tavaline sündmus'; + + @override + String get googleFocusTime => 'Keskendumisaeg'; + + @override + String get googleOutOfOffice => 'Kontorist väljas'; + + @override + String get googleWorkingLocation => 'Tööasukoht'; + + @override + String get googleDeclineInvitations => 'Keeldu kattuvatest kutsetest'; + + @override + String get googleDeclineNone => 'Ära keeldu'; + + @override + String get googleDeclineNew => 'Keeldu uutest kutsetest'; + + @override + String get googleDeclineAll => 'Keeldu kõigist kattuvatest kutsetest'; + + @override + String get googleDeclineMessage => 'Keeldumissõnum'; + + @override + String get googleChatStatus => 'Vestluse olek'; + + @override + String get googleChatAvailable => 'Saadaval'; + + @override + String get googleChatDoNotDisturb => 'Ära sega'; + + @override + String get googleWorkAtHome => 'Kodus'; + + @override + String get googleWorkAtOffice => 'Kontoris'; + + @override + String get googleWorkAtCustomLocation => 'Kohandatud asukoht'; + + @override + String get googleWorkLocationLabel => 'Asukoha nimi'; + + @override + String get googleStatusPrimaryOnly => + 'Google’i olekusündmused nõuavad peamist Google’i kalendrit.'; + + @override + String unknownEventLabel(String id) { + return 'Tundmatu silt ($id)'; + } + + @override + String get calendarOwnerEmail => 'Kalendri omaniku e-posti aadress'; } diff --git a/lib/l10n/generated/app_localizations_fa.dart b/lib/l10n/generated/app_localizations_fa.dart index fd07dd47..5a4bb954 100644 --- a/lib/l10n/generated/app_localizations_fa.dart +++ b/lib/l10n/generated/app_localizations_fa.dart @@ -543,6 +543,18 @@ class AppLocalizationsFa extends AppLocalizations { @override String get openInProvider => 'باز کردن در سرویس'; + @override + String get linkedResources => 'منابع پیوندشده'; + + @override + String get noLinkedResources => 'منبع پیوندشده‌ای وجود ندارد'; + + @override + String get attachments => 'پیوست‌ها'; + + @override + String get attachmentsNotLoaded => 'پیوست‌ها بارگیری نشده‌اند'; + @override String get hideFromSchedule => 'پنهان کردن از برنامه'; @@ -2019,6 +2031,10 @@ class AppLocalizationsFa extends AppLocalizations { return '«⁨⁨$title⁩⁩» حذف شود؟'; } + @override + String get deleteAssignedTaskWarning => + 'این کار، وظیفهٔ اصلی را نیز در Google Docs یا فضاهای Chat حذف می‌کند.'; + @override String get metadata => 'فراداده'; @@ -2646,6 +2662,15 @@ class AppLocalizationsFa extends AppLocalizations { @override String get removeAction => 'حذف'; + @override + String removeOpenedSharedCalendarConfirmation(String title) { + return '«⁨$title⁩» از BusyMax حذف شود؟ تقویم و رویدادهای مالک حذف نخواهند شد.'; + } + + @override + String get attachmentUploadUnresolved => + 'ممکن است بارگذاری کامل شده باشد. پیش از بارگذاری دوباره، پیوست‌ها را تازه‌سازی کنید تا نتیجه مشخص شود.'; + @override String removeCalendarConfirmation(String title) { return '«⁨$title⁩» از فهرست تقویم Google شما حذف شود؟ تقویم اشتراکی و رویدادهای آن حذف نخواهند شد.'; @@ -3002,7 +3027,17 @@ class AppLocalizationsFa extends AppLocalizations { String get searchDate => 'تاریخ'; @override - String get searchAnyDate => 'هر تاریخی'; + String get searchAnyDate => 'هر تاریخی (داده‌های بارگیری‌شده)'; + + @override + String get eventLink => 'پیوند رویداد'; + + @override + String get eventLinkOpenFailed => 'باز کردن پیوند ممکن نشد.'; + + @override + String get scheduleRangeIncomplete => + 'بررسی برخی رویدادها ممکن نشد. داده‌های بارگیری‌شده نمایش داده می‌شوند.'; @override String get searchThisWeek => 'این هفته'; @@ -3039,4 +3074,126 @@ class AppLocalizationsFa extends AppLocalizations { @override String get searchClearText => 'پاک کردن متن'; + + @override + String get openSharedCalendar => 'باز کردن تقویم اشتراکی'; + + @override + String get manageCalendarSharing => 'مدیریت اشتراک‌گذاری تقویم'; + + @override + String get shareRecipientEmail => 'ایمیل گیرنده'; + + @override + String get shareRole => 'نقش دسترسی'; + + @override + String get addCalendarShare => 'افزودن دسترسی'; + + @override + String get sharingRefreshFailed => + 'تغییر اشتراک‌گذاری انجام شد، اما فهرست مجوزها به‌روز نشد. پیش از تغییر دیگر فهرست را دوباره بارگیری کنید.'; + + @override + String get sharingRateLimited => + 'اشتراک‌گذاری موقتاً محدود شده است. پس از پایان زمان انتظار دوباره تلاش کنید.'; + + @override + String get attachmentUploadRateLimited => + 'بارگذاری پیوست‌ها موقتاً محدود شده است. پس از پایان زمان انتظار دوباره تلاش کنید.'; + + @override + String get sharingPermissionUnavailable => + 'مجوزهای اشتراک‌گذاری برای این تقویم در دسترس نیست.'; + + @override + String get calendarShareFreeBusy => 'فقط آزاد/مشغول'; + + @override + String get calendarShareLimitedRead => 'خواندن جزئیات محدود'; + + @override + String get calendarShareRead => 'خواندن همه جزئیات'; + + @override + String get calendarShareWrite => 'ویرایش رویدادها'; + + @override + String get calendarShareWriteWithoutPrivate => 'ویرایش بدون جزئیات خصوصی'; + + @override + String get calendarShareOwner => 'مالک'; + + @override + String get eventLabel => 'برچسب رویداد'; + + @override + String get loadOutlookCategories => 'بارگیری دسته‌های Outlook'; + + @override + String get outlookCategoriesUnavailable => + 'دسته‌های Outlook در دسترس نیستند؛ انتساب‌های موجود حفظ می‌شوند.'; + + @override + String get googleEventType => 'نوع رویداد'; + + @override + String get googleRegularEvent => 'رویداد عادی'; + + @override + String get googleFocusTime => 'زمان تمرکز'; + + @override + String get googleOutOfOffice => 'خارج از دفتر'; + + @override + String get googleWorkingLocation => 'محل کار'; + + @override + String get googleDeclineInvitations => 'رد دعوت‌های هم‌زمان'; + + @override + String get googleDeclineNone => 'رد نکن'; + + @override + String get googleDeclineNew => 'رد دعوت‌های جدید'; + + @override + String get googleDeclineAll => 'رد همه دعوت‌های متداخل'; + + @override + String get googleDeclineMessage => 'پیام رد'; + + @override + String get googleChatStatus => 'وضعیت گفتگو'; + + @override + String get googleChatAvailable => 'در دسترس'; + + @override + String get googleChatDoNotDisturb => 'مزاحم نشوید'; + + @override + String get googleWorkAtHome => 'خانه'; + + @override + String get googleWorkAtOffice => 'دفتر'; + + @override + String get googleWorkAtCustomLocation => 'مکان سفارشی'; + + @override + String get googleWorkLocationLabel => 'عنوان مکان'; + + @override + String get googleStatusPrimaryOnly => + 'رویدادهای وضعیت Google به تقویم اصلی Google نیاز دارند.'; + + @override + String unknownEventLabel(String id) { + return 'برچسب ناشناخته (⁨$id⁩)'; + } + + @override + String get calendarOwnerEmail => 'ایمیل مالک تقویم'; } diff --git a/lib/l10n/generated/app_localizations_fi.dart b/lib/l10n/generated/app_localizations_fi.dart index 7640c252..083486dd 100644 --- a/lib/l10n/generated/app_localizations_fi.dart +++ b/lib/l10n/generated/app_localizations_fi.dart @@ -546,6 +546,18 @@ class AppLocalizationsFi extends AppLocalizations { @override String get openInProvider => 'Avaa palvelussa'; + @override + String get linkedResources => 'Linkitetyt resurssit'; + + @override + String get noLinkedResources => 'Ei linkitettyjä resursseja'; + + @override + String get attachments => 'Liitteet'; + + @override + String get attachmentsNotLoaded => 'Liitteitä ei ladattu'; + @override String get hideFromSchedule => 'Piilota aikataulusta'; @@ -2013,6 +2025,10 @@ class AppLocalizationsFi extends AppLocalizations { return 'Poistetaanko \"$title\"?'; } + @override + String get deleteAssignedTaskWarning => + 'Tämä poistaa myös alkuperäisen tehtävän Google Docsista tai Chat-tiloista.'; + @override String get metadata => 'Metatiedot'; @@ -2643,6 +2659,15 @@ class AppLocalizationsFi extends AppLocalizations { @override String get removeAction => 'Poista'; + @override + String removeOpenedSharedCalendarConfirmation(String title) { + return 'Poistetaanko ”$title” BusyMaxista? Omistajan kalenteria tai tapahtumia ei poisteta.'; + } + + @override + String get attachmentUploadUnresolved => + 'Lataus saattoi onnistua. Päivitä liitteet ja tarkista tulos ennen uutta latausta.'; + @override String removeCalendarConfirmation(String title) { return 'Poistetaanko ”$title” Google Kalenterin luettelostasi? Jaettua kalenteria tai sen tapahtumia ei poisteta.'; @@ -2996,7 +3021,17 @@ class AppLocalizationsFi extends AppLocalizations { String get searchDate => 'Päivämäärä'; @override - String get searchAnyDate => 'Kaikki päivämäärät'; + String get searchAnyDate => 'Kaikki päivämäärät (ladatut tiedot)'; + + @override + String get eventLink => 'Tapahtuman linkki'; + + @override + String get eventLinkOpenFailed => 'Linkkiä ei voitu avata.'; + + @override + String get scheduleRangeIncomplete => + 'Kaikkia tapahtumia ei voitu tarkistaa. Näytetään ladatut tiedot.'; @override String get searchThisWeek => 'Tämä viikko'; @@ -3033,4 +3068,127 @@ class AppLocalizationsFi extends AppLocalizations { @override String get searchClearText => 'Tyhjennä teksti'; + + @override + String get openSharedCalendar => 'Avaa jaettu kalenteri'; + + @override + String get manageCalendarSharing => 'Hallitse kalenterin jakamista'; + + @override + String get shareRecipientEmail => 'Vastaanottajan sähköposti'; + + @override + String get shareRole => 'Käyttöoikeusrooli'; + + @override + String get addCalendarShare => 'Lisää käyttöoikeus'; + + @override + String get sharingRefreshFailed => + 'Jako muutettiin, mutta oikeusluetteloa ei voitu päivittää. Lataa luettelo uudelleen ennen seuraavaa muutosta.'; + + @override + String get sharingRateLimited => + 'Jakamista on rajoitettu tilapäisesti. Yritä uudelleen odotusajan päätyttyä.'; + + @override + String get attachmentUploadRateLimited => + 'Liitteiden lataamista on rajoitettu tilapäisesti. Yritä uudelleen odotusajan päätyttyä.'; + + @override + String get sharingPermissionUnavailable => + 'Tämän kalenterin jako-oikeudet eivät ole saatavilla.'; + + @override + String get calendarShareFreeBusy => 'Vain vapaa/varattu'; + + @override + String get calendarShareLimitedRead => 'Lue rajatut tiedot'; + + @override + String get calendarShareRead => 'Lue kaikki tiedot'; + + @override + String get calendarShareWrite => 'Muokkaa tapahtumia'; + + @override + String get calendarShareWriteWithoutPrivate => + 'Muokkaa ilman yksityisiä tietoja'; + + @override + String get calendarShareOwner => 'Omistaja'; + + @override + String get eventLabel => 'Tapahtuman tunniste'; + + @override + String get loadOutlookCategories => 'Lataa Outlook-luokat'; + + @override + String get outlookCategoriesUnavailable => + 'Outlook-luokat eivät ole saatavilla; aiemmat luokitukset säilyvät.'; + + @override + String get googleEventType => 'Tapahtuman tyyppi'; + + @override + String get googleRegularEvent => 'Tavallinen tapahtuma'; + + @override + String get googleFocusTime => 'Keskittymisaika'; + + @override + String get googleOutOfOffice => 'Poissa toimistolta'; + + @override + String get googleWorkingLocation => 'Työskentelypaikka'; + + @override + String get googleDeclineInvitations => 'Hylkää päällekkäiset kutsut'; + + @override + String get googleDeclineNone => 'Älä hylkää'; + + @override + String get googleDeclineNew => 'Hylkää uudet kutsut'; + + @override + String get googleDeclineAll => 'Hylkää kaikki päällekkäiset kutsut'; + + @override + String get googleDeclineMessage => 'Hylkäysviesti'; + + @override + String get googleChatStatus => 'Keskustelun tila'; + + @override + String get googleChatAvailable => 'Saatavilla'; + + @override + String get googleChatDoNotDisturb => 'Älä häiritse'; + + @override + String get googleWorkAtHome => 'Kotona'; + + @override + String get googleWorkAtOffice => 'Toimistolla'; + + @override + String get googleWorkAtCustomLocation => 'Mukautettu sijainti'; + + @override + String get googleWorkLocationLabel => 'Sijainnin nimi'; + + @override + String get googleStatusPrimaryOnly => + 'Googlen tilatapahtumat vaativat ensisijaisen Google-kalenterin.'; + + @override + String unknownEventLabel(String id) { + return 'Tuntematon tunniste ($id)'; + } + + @override + String get calendarOwnerEmail => 'Kalenterin omistajan sähköpostiosoite'; } diff --git a/lib/l10n/generated/app_localizations_fr.dart b/lib/l10n/generated/app_localizations_fr.dart index cd886a3a..d50bfecd 100644 --- a/lib/l10n/generated/app_localizations_fr.dart +++ b/lib/l10n/generated/app_localizations_fr.dart @@ -551,6 +551,18 @@ class AppLocalizationsFr extends AppLocalizations { @override String get openInProvider => 'Ouvrir dans le service'; + @override + String get linkedResources => 'Ressources liées'; + + @override + String get noLinkedResources => 'Aucune ressource liée'; + + @override + String get attachments => 'Pièces jointes'; + + @override + String get attachmentsNotLoaded => 'Pièces jointes non chargées'; + @override String get hideFromSchedule => 'Masquer du planning'; @@ -2004,6 +2016,10 @@ class AppLocalizationsFr extends AppLocalizations { return 'Supprimer « $title » ?'; } + @override + String get deleteAssignedTaskWarning => + 'Cela supprime aussi la tâche d’origine dans Google Docs ou les espaces Chat.'; + @override String get metadata => 'Métadonnées'; @@ -2633,6 +2649,15 @@ class AppLocalizationsFr extends AppLocalizations { @override String get removeAction => 'Retirer'; + @override + String removeOpenedSharedCalendarConfirmation(String title) { + return 'Retirer « $title » de BusyMax ? L’agenda et les événements du propriétaire ne seront pas supprimés.'; + } + + @override + String get attachmentUploadUnresolved => + 'L’envoi a peut-être réussi. Actualisez les pièces jointes pour vérifier le résultat avant de réessayer.'; + @override String removeCalendarConfirmation(String title) { return 'Retirer « $title » de votre liste Google Agenda ? L’agenda partagé et ses événements ne seront pas supprimés.'; @@ -2972,7 +2997,17 @@ class AppLocalizationsFr extends AppLocalizations { String get searchDate => 'Date de recherche'; @override - String get searchAnyDate => 'Toutes les dates'; + String get searchAnyDate => 'Toutes les dates (données téléchargées)'; + + @override + String get eventLink => 'Lien de l’événement'; + + @override + String get eventLinkOpenFailed => 'Impossible d’ouvrir le lien.'; + + @override + String get scheduleRangeIncomplete => + 'Certains événements n’ont pas pu être vérifiés. Les données téléchargées sont affichées.'; @override String get searchThisWeek => 'Cette semaine'; @@ -3009,4 +3044,129 @@ class AppLocalizationsFr extends AppLocalizations { @override String get searchClearText => 'Effacer le texte'; + + @override + String get openSharedCalendar => 'Ouvrir un calendrier partagé'; + + @override + String get manageCalendarSharing => 'Gérer le partage de l’agenda'; + + @override + String get shareRecipientEmail => 'Adresse e-mail du destinataire'; + + @override + String get shareRole => 'Rôle d’accès'; + + @override + String get addCalendarShare => 'Ajouter un accès'; + + @override + String get sharingRefreshFailed => + 'Le partage a été modifié, mais la liste des autorisations n’a pas pu être actualisée. Rechargez-la avant une autre modification.'; + + @override + String get sharingRateLimited => + 'Le partage est temporairement limité. Réessayez après le délai d’attente.'; + + @override + String get attachmentUploadRateLimited => + 'L’envoi de pièces jointes est temporairement limité. Réessayez après le délai d’attente.'; + + @override + String get sharingPermissionUnavailable => + 'Les autorisations de partage sont indisponibles pour cet agenda.'; + + @override + String get calendarShareFreeBusy => 'Disponibilité uniquement'; + + @override + String get calendarShareLimitedRead => 'Lire les détails limités'; + + @override + String get calendarShareRead => 'Lire tous les détails'; + + @override + String get calendarShareWrite => 'Modifier les événements'; + + @override + String get calendarShareWriteWithoutPrivate => 'Modifier sans détails privés'; + + @override + String get calendarShareOwner => 'Propriétaire'; + + @override + String get eventLabel => 'Étiquette de l’événement'; + + @override + String get loadOutlookCategories => 'Charger les catégories Outlook'; + + @override + String get outlookCategoriesUnavailable => + 'Les catégories Outlook sont indisponibles; les affectations existantes sont conservées.'; + + @override + String get googleEventType => 'Type d’événement'; + + @override + String get googleRegularEvent => 'Événement ordinaire'; + + @override + String get googleFocusTime => 'Temps de concentration'; + + @override + String get googleOutOfOffice => 'Absent du bureau'; + + @override + String get googleWorkingLocation => 'Lieu de travail'; + + @override + String get googleDeclineInvitations => + 'Refuser les invitations qui se chevauchent'; + + @override + String get googleDeclineNone => 'Ne pas refuser'; + + @override + String get googleDeclineNew => 'Refuser les nouvelles invitations'; + + @override + String get googleDeclineAll => + 'Refuser toutes les invitations conflictuelles'; + + @override + String get googleDeclineMessage => 'Message de refus'; + + @override + String get googleChatStatus => 'Statut de discussion'; + + @override + String get googleChatAvailable => 'Disponible'; + + @override + String get googleChatDoNotDisturb => 'Ne pas déranger'; + + @override + String get googleWorkAtHome => 'Domicile'; + + @override + String get googleWorkAtOffice => 'Bureau'; + + @override + String get googleWorkAtCustomLocation => 'Lieu personnalisé'; + + @override + String get googleWorkLocationLabel => 'Nom du lieu'; + + @override + String get googleStatusPrimaryOnly => + 'Les événements de statut Google nécessitent votre agenda Google principal.'; + + @override + String unknownEventLabel(String id) { + return 'Étiquette inconnue ($id)'; + } + + @override + String get calendarOwnerEmail => + 'Adresse e-mail du propriétaire du calendrier'; } diff --git a/lib/l10n/generated/app_localizations_hi.dart b/lib/l10n/generated/app_localizations_hi.dart index 9d8eb84e..888b60e8 100644 --- a/lib/l10n/generated/app_localizations_hi.dart +++ b/lib/l10n/generated/app_localizations_hi.dart @@ -545,6 +545,18 @@ class AppLocalizationsHi extends AppLocalizations { @override String get openInProvider => 'सेवा में खोलें'; + @override + String get linkedResources => 'लिंक किए गए संसाधन'; + + @override + String get noLinkedResources => 'कोई लिंक किया गया संसाधन नहीं'; + + @override + String get attachments => 'अटैचमेंट'; + + @override + String get attachmentsNotLoaded => 'अटैचमेंट लोड नहीं हुए'; + @override String get hideFromSchedule => 'शेड्यूल से छिपाएँ'; @@ -2004,6 +2016,10 @@ class AppLocalizationsHi extends AppLocalizations { return '“$title” मिटाएँ?'; } + @override + String get deleteAssignedTaskWarning => + 'इससे Google Docs या Chat Spaces में मूल टास्क भी मिट जाएगा।'; + @override String get metadata => 'मेटाडेटा'; @@ -2628,6 +2644,15 @@ class AppLocalizationsHi extends AppLocalizations { @override String get removeAction => 'हटाएँ'; + @override + String removeOpenedSharedCalendarConfirmation(String title) { + return 'क्या \"$title\" को BusyMax से हटाएँ? मालिक का कैलेंडर और उसके इवेंट नहीं मिटाए जाएँगे।'; + } + + @override + String get attachmentUploadUnresolved => + 'अपलोड पूरा हो गया हो सकता है। दोबारा अपलोड करने से पहले परिणाम जाँचने के लिए अटैचमेंट रीफ़्रेश करें।'; + @override String removeCalendarConfirmation(String title) { return 'क्या \"$title\" को आपकी Google Calendar सूची से हटाएँ? साझा कैलेंडर और उसके इवेंट नहीं मिटाए जाएँगे।'; @@ -2967,7 +2992,17 @@ class AppLocalizationsHi extends AppLocalizations { String get searchDate => 'तारीख'; @override - String get searchAnyDate => 'कोई भी तारीख'; + String get searchAnyDate => 'कोई भी तारीख (डाउनलोड किया गया डेटा)'; + + @override + String get eventLink => 'इवेंट लिंक'; + + @override + String get eventLinkOpenFailed => 'लिंक नहीं खोला जा सका।'; + + @override + String get scheduleRangeIncomplete => + 'कुछ इवेंट की जाँच नहीं हो सकी। डाउनलोड किया गया डेटा दिखाया जा रहा है।'; @override String get searchThisWeek => 'इस सप्ताह'; @@ -3004,4 +3039,127 @@ class AppLocalizationsHi extends AppLocalizations { @override String get searchClearText => 'पाठ साफ़ करें'; + + @override + String get openSharedCalendar => 'साझा कैलेंडर खोलें'; + + @override + String get manageCalendarSharing => 'कैलेंडर साझाकरण प्रबंधित करें'; + + @override + String get shareRecipientEmail => 'प्राप्तकर्ता का ईमेल'; + + @override + String get shareRole => 'पहुँच की भूमिका'; + + @override + String get addCalendarShare => 'पहुँच जोड़ें'; + + @override + String get sharingRefreshFailed => + 'साझाकरण बदल गया, लेकिन अनुमतियों की सूची ताज़ा नहीं हो सकी। अगला बदलाव करने से पहले सूची फिर लोड करें।'; + + @override + String get sharingRateLimited => + 'साझाकरण अस्थायी रूप से सीमित है। प्रतीक्षा अवधि समाप्त होने पर फिर प्रयास करें।'; + + @override + String get attachmentUploadRateLimited => + 'अटैचमेंट अपलोड अस्थायी रूप से सीमित है। प्रतीक्षा अवधि समाप्त होने पर फिर प्रयास करें।'; + + @override + String get sharingPermissionUnavailable => + 'इस कैलेंडर के लिए साझाकरण अनुमतियाँ उपलब्ध नहीं हैं।'; + + @override + String get calendarShareFreeBusy => 'केवल खाली/व्यस्त'; + + @override + String get calendarShareLimitedRead => 'सीमित विवरण पढ़ें'; + + @override + String get calendarShareRead => 'सभी विवरण पढ़ें'; + + @override + String get calendarShareWrite => 'कार्यक्रम संपादित करें'; + + @override + String get calendarShareWriteWithoutPrivate => + 'निजी विवरण के बिना संपादित करें'; + + @override + String get calendarShareOwner => 'स्वामी'; + + @override + String get eventLabel => 'कार्यक्रम लेबल'; + + @override + String get loadOutlookCategories => 'Outlook श्रेणियाँ लोड करें'; + + @override + String get outlookCategoriesUnavailable => + 'Outlook श्रेणियाँ उपलब्ध नहीं हैं; मौजूदा श्रेणियाँ बनी रहेंगी।'; + + @override + String get googleEventType => 'कार्यक्रम का प्रकार'; + + @override + String get googleRegularEvent => 'सामान्य कार्यक्रम'; + + @override + String get googleFocusTime => 'एकाग्रता का समय'; + + @override + String get googleOutOfOffice => 'कार्यालय से बाहर'; + + @override + String get googleWorkingLocation => 'कार्य स्थान'; + + @override + String get googleDeclineInvitations => 'साथ आने वाले आमंत्रण अस्वीकार करें'; + + @override + String get googleDeclineNone => 'अस्वीकार न करें'; + + @override + String get googleDeclineNew => 'नए आमंत्रण अस्वीकार करें'; + + @override + String get googleDeclineAll => 'सभी टकराने वाले आमंत्रण अस्वीकार करें'; + + @override + String get googleDeclineMessage => 'अस्वीकृति संदेश'; + + @override + String get googleChatStatus => 'चैट स्थिति'; + + @override + String get googleChatAvailable => 'उपलब्ध'; + + @override + String get googleChatDoNotDisturb => 'परेशान न करें'; + + @override + String get googleWorkAtHome => 'घर'; + + @override + String get googleWorkAtOffice => 'कार्यालय'; + + @override + String get googleWorkAtCustomLocation => 'कस्टम स्थान'; + + @override + String get googleWorkLocationLabel => 'स्थान का नाम'; + + @override + String get googleStatusPrimaryOnly => + 'Google स्थिति कार्यक्रम के लिए आपका मुख्य Google कैलेंडर आवश्यक है।'; + + @override + String unknownEventLabel(String id) { + return 'अज्ञात लेबल ($id)'; + } + + @override + String get calendarOwnerEmail => 'कैलेंडर स्वामी का ईमेल'; } diff --git a/lib/l10n/generated/app_localizations_it.dart b/lib/l10n/generated/app_localizations_it.dart index 5e8c0190..c6c44ce1 100644 --- a/lib/l10n/generated/app_localizations_it.dart +++ b/lib/l10n/generated/app_localizations_it.dart @@ -549,6 +549,18 @@ class AppLocalizationsIt extends AppLocalizations { @override String get openInProvider => 'Apri nel servizio'; + @override + String get linkedResources => 'Risorse collegate'; + + @override + String get noLinkedResources => 'Nessuna risorsa collegata'; + + @override + String get attachments => 'Allegati'; + + @override + String get attachmentsNotLoaded => 'Allegati non caricati'; + @override String get hideFromSchedule => 'Nascondi dall’agenda'; @@ -2016,6 +2028,10 @@ class AppLocalizationsIt extends AppLocalizations { return 'Eliminare «$title»?'; } + @override + String get deleteAssignedTaskWarning => + 'Questa operazione elimina anche l’attività originale in Google Docs o negli spazi di Chat.'; + @override String get metadata => 'Metadati'; @@ -2650,6 +2666,15 @@ class AppLocalizationsIt extends AppLocalizations { @override String get removeAction => 'Rimuovi'; + @override + String removeOpenedSharedCalendarConfirmation(String title) { + return 'Rimuovere \"$title\" da BusyMax? Il calendario e gli eventi del proprietario non verranno eliminati.'; + } + + @override + String get attachmentUploadUnresolved => + 'Il caricamento potrebbe essere riuscito. Aggiorna gli allegati per verificarlo prima di riprovare.'; + @override String removeCalendarConfirmation(String title) { return 'Rimuovere \"$title\" dall\'elenco di Google Calendar? Il calendario condiviso e i relativi eventi non verranno eliminati.'; @@ -2988,7 +3013,17 @@ class AppLocalizationsIt extends AppLocalizations { String get searchDate => 'Data'; @override - String get searchAnyDate => 'Qualsiasi data'; + String get searchAnyDate => 'Qualsiasi data (dati scaricati)'; + + @override + String get eventLink => 'Link dell’evento'; + + @override + String get eventLinkOpenFailed => 'Impossibile aprire il link.'; + + @override + String get scheduleRangeIncomplete => + 'Alcuni eventi non sono stati verificati. Sono mostrati i dati scaricati.'; @override String get searchThisWeek => 'Questa settimana'; @@ -3025,4 +3060,127 @@ class AppLocalizationsIt extends AppLocalizations { @override String get searchClearText => 'Cancella testo'; + + @override + String get openSharedCalendar => 'Apri calendario condiviso'; + + @override + String get manageCalendarSharing => 'Gestisci condivisione del calendario'; + + @override + String get shareRecipientEmail => 'Email del destinatario'; + + @override + String get shareRole => 'Ruolo di accesso'; + + @override + String get addCalendarShare => 'Aggiungi accesso'; + + @override + String get sharingRefreshFailed => + 'La condivisione è stata modificata, ma l’elenco delle autorizzazioni non è stato aggiornato. Ricaricalo prima di un’altra modifica.'; + + @override + String get sharingRateLimited => + 'La condivisione è temporaneamente limitata. Riprova al termine dell’attesa.'; + + @override + String get attachmentUploadRateLimited => + 'Il caricamento degli allegati è temporaneamente limitato. Riprova al termine dell’attesa.'; + + @override + String get sharingPermissionUnavailable => + 'Le autorizzazioni di condivisione non sono disponibili per questo calendario.'; + + @override + String get calendarShareFreeBusy => 'Solo libero/occupato'; + + @override + String get calendarShareLimitedRead => 'Leggi dettagli limitati'; + + @override + String get calendarShareRead => 'Leggi tutti i dettagli'; + + @override + String get calendarShareWrite => 'Modifica eventi'; + + @override + String get calendarShareWriteWithoutPrivate => + 'Modifica senza dettagli privati'; + + @override + String get calendarShareOwner => 'Proprietario'; + + @override + String get eventLabel => 'Etichetta evento'; + + @override + String get loadOutlookCategories => 'Carica categorie Outlook'; + + @override + String get outlookCategoriesUnavailable => + 'Le categorie Outlook non sono disponibili; le assegnazioni esistenti vengono conservate.'; + + @override + String get googleEventType => 'Tipo di evento'; + + @override + String get googleRegularEvent => 'Evento normale'; + + @override + String get googleFocusTime => 'Tempo di concentrazione'; + + @override + String get googleOutOfOffice => 'Fuori sede'; + + @override + String get googleWorkingLocation => 'Luogo di lavoro'; + + @override + String get googleDeclineInvitations => 'Rifiuta gli inviti sovrapposti'; + + @override + String get googleDeclineNone => 'Non rifiutare'; + + @override + String get googleDeclineNew => 'Rifiuta i nuovi inviti'; + + @override + String get googleDeclineAll => 'Rifiuta tutti gli inviti in conflitto'; + + @override + String get googleDeclineMessage => 'Messaggio di rifiuto'; + + @override + String get googleChatStatus => 'Stato chat'; + + @override + String get googleChatAvailable => 'Disponibile'; + + @override + String get googleChatDoNotDisturb => 'Non disturbare'; + + @override + String get googleWorkAtHome => 'Casa'; + + @override + String get googleWorkAtOffice => 'Ufficio'; + + @override + String get googleWorkAtCustomLocation => 'Luogo personalizzato'; + + @override + String get googleWorkLocationLabel => 'Nome del luogo'; + + @override + String get googleStatusPrimaryOnly => + 'Gli eventi di stato Google richiedono il calendario Google principale.'; + + @override + String unknownEventLabel(String id) { + return 'Etichetta sconosciuta ($id)'; + } + + @override + String get calendarOwnerEmail => 'Email del proprietario del calendario'; } diff --git a/lib/l10n/generated/app_localizations_ja.dart b/lib/l10n/generated/app_localizations_ja.dart index a4fb64d3..3f5575e0 100644 --- a/lib/l10n/generated/app_localizations_ja.dart +++ b/lib/l10n/generated/app_localizations_ja.dart @@ -528,6 +528,18 @@ class AppLocalizationsJa extends AppLocalizations { @override String get openInProvider => 'サービスで開く'; + @override + String get linkedResources => 'リンクされたリソース'; + + @override + String get noLinkedResources => 'リンクされたリソースはありません'; + + @override + String get attachments => '添付ファイル'; + + @override + String get attachmentsNotLoaded => '添付ファイルは読み込まれていません'; + @override String get hideFromSchedule => 'スケジュールから非表示'; @@ -1960,6 +1972,10 @@ class AppLocalizationsJa extends AppLocalizations { return '「$title」を削除しますか?'; } + @override + String get deleteAssignedTaskWarning => + 'Google ドキュメントまたは Chat スペースの元のタスクも削除されます。'; + @override String get metadata => 'メタデータ'; @@ -2571,6 +2587,15 @@ class AppLocalizationsJa extends AppLocalizations { @override String get removeAction => '削除'; + @override + String removeOpenedSharedCalendarConfirmation(String title) { + return '「$title」を BusyMax から削除しますか?所有者のカレンダーと予定は削除されません。'; + } + + @override + String get attachmentUploadUnresolved => + 'アップロードが完了した可能性があります。再アップロードする前に添付ファイルを更新して結果を確認してください。'; + @override String removeCalendarConfirmation(String title) { return '「$title」を Google カレンダーのリストから削除しますか?共有カレンダーとその予定は削除されません。'; @@ -2904,7 +2929,16 @@ class AppLocalizationsJa extends AppLocalizations { String get searchDate => '日付'; @override - String get searchAnyDate => 'すべての日付'; + String get searchAnyDate => 'すべての日付(ダウンロード済みデータ)'; + + @override + String get eventLink => '予定のリンク'; + + @override + String get eventLinkOpenFailed => 'リンクを開けませんでした。'; + + @override + String get scheduleRangeIncomplete => '一部の予定を確認できませんでした。ダウンロード済みデータを表示しています。'; @override String get searchThisWeek => '今週'; @@ -2941,4 +2975,124 @@ class AppLocalizationsJa extends AppLocalizations { @override String get searchClearText => 'テキストを消去'; + + @override + String get openSharedCalendar => '共有カレンダーを開く'; + + @override + String get manageCalendarSharing => 'カレンダーの共有を管理'; + + @override + String get shareRecipientEmail => '共有先のメールアドレス'; + + @override + String get shareRole => 'アクセス権'; + + @override + String get addCalendarShare => 'アクセスを追加'; + + @override + String get sharingRefreshFailed => + '共有は変更されましたが、権限一覧を更新できませんでした。次の変更前に一覧を再読み込みしてください。'; + + @override + String get sharingRateLimited => '共有は一時的に制限されています。待機時間が終了してから再試行してください。'; + + @override + String get attachmentUploadRateLimited => + '添付ファイルのアップロードは一時的に制限されています。待機時間が終了してから再試行してください。'; + + @override + String get sharingPermissionUnavailable => 'このカレンダーの共有権限を取得できません。'; + + @override + String get calendarShareFreeBusy => '空き時間情報のみ'; + + @override + String get calendarShareLimitedRead => '限定された詳細を表示'; + + @override + String get calendarShareRead => 'すべての詳細を表示'; + + @override + String get calendarShareWrite => '予定を編集'; + + @override + String get calendarShareWriteWithoutPrivate => '非公開の詳細を除いて編集'; + + @override + String get calendarShareOwner => '所有者'; + + @override + String get eventLabel => '予定のラベル'; + + @override + String get loadOutlookCategories => 'Outlook カテゴリを読み込む'; + + @override + String get outlookCategoriesUnavailable => + 'Outlook カテゴリを取得できません。既存の割り当ては保持されます。'; + + @override + String get googleEventType => '予定の種類'; + + @override + String get googleRegularEvent => '通常の予定'; + + @override + String get googleFocusTime => '集中する時間'; + + @override + String get googleOutOfOffice => '不在'; + + @override + String get googleWorkingLocation => '勤務場所'; + + @override + String get googleDeclineInvitations => '重なる招待を辞退'; + + @override + String get googleDeclineNone => '辞退しない'; + + @override + String get googleDeclineNew => '新しい招待を辞退'; + + @override + String get googleDeclineAll => '競合する招待をすべて辞退'; + + @override + String get googleDeclineMessage => '辞退メッセージ'; + + @override + String get googleChatStatus => 'チャットの状態'; + + @override + String get googleChatAvailable => '連絡可能'; + + @override + String get googleChatDoNotDisturb => '通知をミュート'; + + @override + String get googleWorkAtHome => '自宅'; + + @override + String get googleWorkAtOffice => 'オフィス'; + + @override + String get googleWorkAtCustomLocation => 'カスタムの場所'; + + @override + String get googleWorkLocationLabel => '場所の名前'; + + @override + String get googleStatusPrimaryOnly => + 'Google のステータス予定にはメインの Google カレンダーが必要です。'; + + @override + String unknownEventLabel(String id) { + return '不明なラベル($id)'; + } + + @override + String get calendarOwnerEmail => 'カレンダー所有者のメールアドレス'; } diff --git a/lib/l10n/generated/app_localizations_ko.dart b/lib/l10n/generated/app_localizations_ko.dart index b968135f..56471540 100644 --- a/lib/l10n/generated/app_localizations_ko.dart +++ b/lib/l10n/generated/app_localizations_ko.dart @@ -530,6 +530,18 @@ class AppLocalizationsKo extends AppLocalizations { @override String get openInProvider => '서비스에서 열기'; + @override + String get linkedResources => '연결된 리소스'; + + @override + String get noLinkedResources => '연결된 리소스가 없습니다'; + + @override + String get attachments => '첨부 파일'; + + @override + String get attachmentsNotLoaded => '첨부 파일을 불러오지 못했습니다'; + @override String get hideFromSchedule => '일정에서 숨기기'; @@ -1964,6 +1976,10 @@ class AppLocalizationsKo extends AppLocalizations { return '“$title” 항목을 삭제할까요?'; } + @override + String get deleteAssignedTaskWarning => + 'Google Docs 또는 Chat 스페이스의 원본 할 일도 삭제됩니다.'; + @override String get metadata => '메타데이터'; @@ -2576,6 +2592,15 @@ class AppLocalizationsKo extends AppLocalizations { @override String get removeAction => '삭제'; + @override + String removeOpenedSharedCalendarConfirmation(String title) { + return 'BusyMax에서 \"$title\"을(를) 제거할까요? 소유자의 캘린더와 일정은 삭제되지 않습니다.'; + } + + @override + String get attachmentUploadUnresolved => + '업로드가 완료되었을 수 있습니다. 다시 업로드하기 전에 첨부 파일을 새로 고쳐 결과를 확인하세요.'; + @override String removeCalendarConfirmation(String title) { return 'Google Calendar 목록에서 \"$title\"을(를) 삭제할까요? 공유 캘린더와 일정은 삭제되지 않습니다.'; @@ -2909,7 +2934,16 @@ class AppLocalizationsKo extends AppLocalizations { String get searchDate => '날짜'; @override - String get searchAnyDate => '모든 날짜'; + String get searchAnyDate => '모든 날짜(다운로드한 데이터)'; + + @override + String get eventLink => '일정 링크'; + + @override + String get eventLinkOpenFailed => '링크를 열 수 없습니다.'; + + @override + String get scheduleRangeIncomplete => '일부 일정을 확인할 수 없습니다. 다운로드한 데이터를 표시합니다.'; @override String get searchThisWeek => '이번 주'; @@ -2946,4 +2980,123 @@ class AppLocalizationsKo extends AppLocalizations { @override String get searchClearText => '텍스트 지우기'; + + @override + String get openSharedCalendar => '공유 캘린더 열기'; + + @override + String get manageCalendarSharing => '캘린더 공유 관리'; + + @override + String get shareRecipientEmail => '받는 사람 이메일'; + + @override + String get shareRole => '액세스 역할'; + + @override + String get addCalendarShare => '액세스 추가'; + + @override + String get sharingRefreshFailed => + '공유 변경은 완료되었지만 권한 목록을 새로 고치지 못했습니다. 다음 변경 전에 다시 불러오세요.'; + + @override + String get sharingRateLimited => '공유가 일시적으로 제한되었습니다. 대기 시간이 끝나면 다시 시도하세요.'; + + @override + String get attachmentUploadRateLimited => + '첨부 파일 업로드가 일시적으로 제한되었습니다. 대기 시간이 끝나면 다시 시도하세요.'; + + @override + String get sharingPermissionUnavailable => '이 캘린더의 공유 권한을 사용할 수 없습니다.'; + + @override + String get calendarShareFreeBusy => '한가함/바쁨만 보기'; + + @override + String get calendarShareLimitedRead => '제한된 세부정보 읽기'; + + @override + String get calendarShareRead => '모든 세부정보 읽기'; + + @override + String get calendarShareWrite => '일정 수정'; + + @override + String get calendarShareWriteWithoutPrivate => '비공개 정보 제외하고 수정'; + + @override + String get calendarShareOwner => '소유자'; + + @override + String get eventLabel => '일정 라벨'; + + @override + String get loadOutlookCategories => 'Outlook 범주 불러오기'; + + @override + String get outlookCategoriesUnavailable => + 'Outlook 범주를 사용할 수 없습니다. 기존 지정은 유지됩니다.'; + + @override + String get googleEventType => '일정 유형'; + + @override + String get googleRegularEvent => '일반 일정'; + + @override + String get googleFocusTime => '집중 시간'; + + @override + String get googleOutOfOffice => '부재중'; + + @override + String get googleWorkingLocation => '근무 위치'; + + @override + String get googleDeclineInvitations => '겹치는 초대 거절'; + + @override + String get googleDeclineNone => '거절하지 않음'; + + @override + String get googleDeclineNew => '새 초대 거절'; + + @override + String get googleDeclineAll => '충돌하는 초대 모두 거절'; + + @override + String get googleDeclineMessage => '거절 메시지'; + + @override + String get googleChatStatus => '채팅 상태'; + + @override + String get googleChatAvailable => '사용 가능'; + + @override + String get googleChatDoNotDisturb => '방해 금지'; + + @override + String get googleWorkAtHome => '집'; + + @override + String get googleWorkAtOffice => '사무실'; + + @override + String get googleWorkAtCustomLocation => '사용자 지정 위치'; + + @override + String get googleWorkLocationLabel => '위치 이름'; + + @override + String get googleStatusPrimaryOnly => 'Google 상태 일정에는 기본 Google 캘린더가 필요합니다.'; + + @override + String unknownEventLabel(String id) { + return '알 수 없는 라벨 ($id)'; + } + + @override + String get calendarOwnerEmail => '캘린더 소유자 이메일'; } diff --git a/lib/l10n/generated/app_localizations_pl.dart b/lib/l10n/generated/app_localizations_pl.dart index 01493364..6a74805a 100644 --- a/lib/l10n/generated/app_localizations_pl.dart +++ b/lib/l10n/generated/app_localizations_pl.dart @@ -559,6 +559,18 @@ class AppLocalizationsPl extends AppLocalizations { @override String get openInProvider => 'Otwórz u dostawcy'; + @override + String get linkedResources => 'Połączone zasoby'; + + @override + String get noLinkedResources => 'Brak połączonych zasobów'; + + @override + String get attachments => 'Załączniki'; + + @override + String get attachmentsNotLoaded => 'Załączniki nie zostały wczytane'; + @override String get hideFromSchedule => 'Ukryj w harmonogramie'; @@ -2144,6 +2156,10 @@ class AppLocalizationsPl extends AppLocalizations { return 'Usunąć „$title”?'; } + @override + String get deleteAssignedTaskWarning => + 'Spowoduje to również usunięcie oryginalnego zadania w Dokumentach Google lub pokojach Chat.'; + @override String get metadata => 'Metadane'; @@ -2782,6 +2798,15 @@ class AppLocalizationsPl extends AppLocalizations { @override String get removeAction => 'Usuń'; + @override + String removeOpenedSharedCalendarConfirmation(String title) { + return 'Usunąć „$title” z BusyMax? Kalendarz właściciela i jego wydarzenia nie zostaną usunięte.'; + } + + @override + String get attachmentUploadUnresolved => + 'Przesyłanie mogło się zakończyć. Odśwież załączniki, aby sprawdzić wynik przed ponownym przesłaniem.'; + @override String removeCalendarConfirmation(String title) { return 'Usunąć „$title” z listy Kalendarza Google? Udostępniony kalendarz i jego wydarzenia nie zostaną usunięte.'; @@ -3141,7 +3166,17 @@ class AppLocalizationsPl extends AppLocalizations { String get searchDate => 'Data'; @override - String get searchAnyDate => 'Dowolna data'; + String get searchAnyDate => 'Dowolna data (pobrane dane)'; + + @override + String get eventLink => 'Link do wydarzenia'; + + @override + String get eventLinkOpenFailed => 'Nie można otworzyć linku.'; + + @override + String get scheduleRangeIncomplete => + 'Nie można było sprawdzić części wydarzeń. Wyświetlane są pobrane dane.'; @override String get searchThisWeek => 'Ten tydzień'; @@ -3178,4 +3213,127 @@ class AppLocalizationsPl extends AppLocalizations { @override String get searchClearText => 'Wyczyść tekst'; + + @override + String get openSharedCalendar => 'Otwórz udostępniony kalendarz'; + + @override + String get manageCalendarSharing => 'Zarządzaj udostępnianiem kalendarza'; + + @override + String get shareRecipientEmail => 'Adres e-mail odbiorcy'; + + @override + String get shareRole => 'Rola dostępu'; + + @override + String get addCalendarShare => 'Dodaj dostęp'; + + @override + String get sharingRefreshFailed => + 'Udostępnienie zmieniono, ale nie udało się odświeżyć listy uprawnień. Wczytaj ją ponownie przed następną zmianą.'; + + @override + String get sharingRateLimited => + 'Udostępnianie jest tymczasowo ograniczone. Spróbuj ponownie po upływie czasu oczekiwania.'; + + @override + String get attachmentUploadRateLimited => + 'Przesyłanie załączników jest tymczasowo ograniczone. Spróbuj ponownie po upływie czasu oczekiwania.'; + + @override + String get sharingPermissionUnavailable => + 'Uprawnienia udostępniania nie są dostępne dla tego kalendarza.'; + + @override + String get calendarShareFreeBusy => 'Tylko wolny/zajęty'; + + @override + String get calendarShareLimitedRead => 'Odczyt ograniczonych szczegółów'; + + @override + String get calendarShareRead => 'Odczyt wszystkich szczegółów'; + + @override + String get calendarShareWrite => 'Edytowanie wydarzeń'; + + @override + String get calendarShareWriteWithoutPrivate => + 'Edytowanie bez prywatnych szczegółów'; + + @override + String get calendarShareOwner => 'Właściciel'; + + @override + String get eventLabel => 'Etykieta wydarzenia'; + + @override + String get loadOutlookCategories => 'Wczytaj kategorie Outlooka'; + + @override + String get outlookCategoriesUnavailable => + 'Kategorie Outlooka są niedostępne; istniejące przypisania zostają zachowane.'; + + @override + String get googleEventType => 'Typ wydarzenia'; + + @override + String get googleRegularEvent => 'Zwykłe wydarzenie'; + + @override + String get googleFocusTime => 'Czas skupienia'; + + @override + String get googleOutOfOffice => 'Poza biurem'; + + @override + String get googleWorkingLocation => 'Miejsce pracy'; + + @override + String get googleDeclineInvitations => 'Odrzuć nakładające się zaproszenia'; + + @override + String get googleDeclineNone => 'Nie odrzucaj'; + + @override + String get googleDeclineNew => 'Odrzuć nowe zaproszenia'; + + @override + String get googleDeclineAll => 'Odrzuć wszystkie kolidujące zaproszenia'; + + @override + String get googleDeclineMessage => 'Wiadomość odmowy'; + + @override + String get googleChatStatus => 'Stan czatu'; + + @override + String get googleChatAvailable => 'Dostępny'; + + @override + String get googleChatDoNotDisturb => 'Nie przeszkadzać'; + + @override + String get googleWorkAtHome => 'Dom'; + + @override + String get googleWorkAtOffice => 'Biuro'; + + @override + String get googleWorkAtCustomLocation => 'Niestandardowe miejsce'; + + @override + String get googleWorkLocationLabel => 'Nazwa miejsca'; + + @override + String get googleStatusPrimaryOnly => + 'Wydarzenia stanu Google wymagają głównego kalendarza Google.'; + + @override + String unknownEventLabel(String id) { + return 'Nieznana etykieta ($id)'; + } + + @override + String get calendarOwnerEmail => 'Adres e-mail właściciela kalendarza'; } diff --git a/lib/l10n/generated/app_localizations_pt.dart b/lib/l10n/generated/app_localizations_pt.dart index d35c96a3..b86bf981 100644 --- a/lib/l10n/generated/app_localizations_pt.dart +++ b/lib/l10n/generated/app_localizations_pt.dart @@ -548,6 +548,18 @@ class AppLocalizationsPt extends AppLocalizations { @override String get openInProvider => 'Open in provider'; + @override + String get linkedResources => 'Linked resources'; + + @override + String get noLinkedResources => 'No linked resources'; + + @override + String get attachments => 'Attachments'; + + @override + String get attachmentsNotLoaded => 'Attachments not loaded'; + @override String get hideFromSchedule => 'Hide from schedule'; @@ -2018,6 +2030,10 @@ class AppLocalizationsPt extends AppLocalizations { return 'Delete \"$title\"?'; } + @override + String get deleteAssignedTaskWarning => + 'This also deletes the original task in Google Docs or Chat Spaces.'; + @override String get metadata => 'Metadata'; @@ -2642,6 +2658,15 @@ class AppLocalizationsPt extends AppLocalizations { @override String get removeAction => 'Remove'; + @override + String removeOpenedSharedCalendarConfirmation(String title) { + return 'Remove \"$title\" from BusyMax? The owner\'s calendar and events will not be deleted.'; + } + + @override + String get attachmentUploadUnresolved => + 'The upload may have completed. Refresh attachments to resolve its outcome before uploading again.'; + @override String removeCalendarConfirmation(String title) { return 'Remove \"$title\" from your Google Calendar list? The shared calendar and its events will not be deleted.'; @@ -2999,7 +3024,17 @@ class AppLocalizationsPt extends AppLocalizations { String get searchDate => 'Date'; @override - String get searchAnyDate => 'Any date'; + String get searchAnyDate => 'Any date (downloaded data)'; + + @override + String get eventLink => 'Event link'; + + @override + String get eventLinkOpenFailed => 'Could not open the link.'; + + @override + String get scheduleRangeIncomplete => + 'Some events could not be checked. Showing downloaded data.'; @override String get searchThisWeek => 'This week'; @@ -3036,6 +3071,128 @@ class AppLocalizationsPt extends AppLocalizations { @override String get searchClearText => 'Clear text'; + + @override + String get openSharedCalendar => 'Open shared calendar'; + + @override + String get manageCalendarSharing => 'Manage calendar sharing'; + + @override + String get shareRecipientEmail => 'Recipient email'; + + @override + String get shareRole => 'Access role'; + + @override + String get addCalendarShare => 'Add access'; + + @override + String get sharingRefreshFailed => + 'The sharing change succeeded, but the permission list could not be refreshed. Retry the list before making another change.'; + + @override + String get sharingRateLimited => + 'Sharing is temporarily rate-limited. Try again when the wait ends.'; + + @override + String get attachmentUploadRateLimited => + 'Attachment upload is temporarily rate-limited. Try again when the wait ends.'; + + @override + String get sharingPermissionUnavailable => + 'Sharing permissions are unavailable for this calendar.'; + + @override + String get calendarShareFreeBusy => 'Free/busy only'; + + @override + String get calendarShareLimitedRead => 'Read limited details'; + + @override + String get calendarShareRead => 'Read all details'; + + @override + String get calendarShareWrite => 'Edit events'; + + @override + String get calendarShareWriteWithoutPrivate => 'Edit without private details'; + + @override + String get calendarShareOwner => 'Owner'; + + @override + String get eventLabel => 'Event label'; + + @override + String get loadOutlookCategories => 'Load Outlook categories'; + + @override + String get outlookCategoriesUnavailable => + 'Outlook categories are unavailable; existing assignments are preserved.'; + + @override + String get googleEventType => 'Event type'; + + @override + String get googleRegularEvent => 'Regular event'; + + @override + String get googleFocusTime => 'Focus time'; + + @override + String get googleOutOfOffice => 'Out of office'; + + @override + String get googleWorkingLocation => 'Working location'; + + @override + String get googleDeclineInvitations => 'Decline overlapping invitations'; + + @override + String get googleDeclineNone => 'Do not decline'; + + @override + String get googleDeclineNew => 'Decline new invitations'; + + @override + String get googleDeclineAll => 'Decline all conflicting invitations'; + + @override + String get googleDeclineMessage => 'Decline message'; + + @override + String get googleChatStatus => 'Chat status'; + + @override + String get googleChatAvailable => 'Available'; + + @override + String get googleChatDoNotDisturb => 'Do not disturb'; + + @override + String get googleWorkAtHome => 'Home'; + + @override + String get googleWorkAtOffice => 'Office'; + + @override + String get googleWorkAtCustomLocation => 'Custom location'; + + @override + String get googleWorkLocationLabel => 'Location label'; + + @override + String get googleStatusPrimaryOnly => + 'Google status events require your primary Google calendar.'; + + @override + String unknownEventLabel(String id) { + return 'Unknown label ($id)'; + } + + @override + String get calendarOwnerEmail => 'Calendar owner email'; } /// The translations for Portuguese, as used in Portugal (`pt_PT`). @@ -3578,6 +3735,18 @@ class AppLocalizationsPtPt extends AppLocalizationsPt { @override String get openInProvider => 'Abrir no serviço'; + @override + String get linkedResources => 'Recursos ligados'; + + @override + String get noLinkedResources => 'Sem recursos ligados'; + + @override + String get attachments => 'Anexos'; + + @override + String get attachmentsNotLoaded => 'Anexos não carregados'; + @override String get hideFromSchedule => 'Ocultar da agenda'; @@ -5044,6 +5213,10 @@ class AppLocalizationsPtPt extends AppLocalizationsPt { return 'Eliminar «$title»?'; } + @override + String get deleteAssignedTaskWarning => + 'Isto também elimina a tarefa original no Google Docs ou nos espaços do Chat.'; + @override String get metadata => 'Metadados'; @@ -5678,6 +5851,15 @@ class AppLocalizationsPtPt extends AppLocalizationsPt { @override String get removeAction => 'Remover'; + @override + String removeOpenedSharedCalendarConfirmation(String title) { + return 'Remover \"$title\" do BusyMax? O calendário e os eventos do proprietário não serão eliminados.'; + } + + @override + String get attachmentUploadUnresolved => + 'O carregamento pode ter sido concluído. Atualize os anexos para confirmar antes de carregar novamente.'; + @override String removeCalendarConfirmation(String title) { return 'Remover \"$title\" da sua lista do Google Calendar? O calendário partilhado e os respetivos eventos não serão eliminados.'; @@ -6017,7 +6199,17 @@ class AppLocalizationsPtPt extends AppLocalizationsPt { String get searchDate => 'Data'; @override - String get searchAnyDate => 'Qualquer data'; + String get searchAnyDate => 'Qualquer data (dados transferidos)'; + + @override + String get eventLink => 'Ligação do evento'; + + @override + String get eventLinkOpenFailed => 'Não foi possível abrir a ligação.'; + + @override + String get scheduleRangeIncomplete => + 'Não foi possível verificar alguns eventos. São apresentados os dados transferidos.'; @override String get searchThisWeek => 'Esta semana'; @@ -6054,4 +6246,126 @@ class AppLocalizationsPtPt extends AppLocalizationsPt { @override String get searchClearText => 'Limpar texto'; + + @override + String get openSharedCalendar => 'Abrir calendário partilhado'; + + @override + String get manageCalendarSharing => 'Gerir partilha do calendário'; + + @override + String get shareRecipientEmail => 'E-mail do destinatário'; + + @override + String get shareRole => 'Papel de acesso'; + + @override + String get addCalendarShare => 'Adicionar acesso'; + + @override + String get sharingRefreshFailed => + 'A partilha foi alterada, mas a lista de permissões não foi atualizada. Recarregue-a antes de fazer outra alteração.'; + + @override + String get sharingRateLimited => + 'A partilha está temporariamente limitada. Volte a tentar quando terminar o tempo de espera.'; + + @override + String get attachmentUploadRateLimited => + 'O carregamento de anexos está temporariamente limitado. Volte a tentar quando terminar o tempo de espera.'; + + @override + String get sharingPermissionUnavailable => + 'As permissões de partilha não estão disponíveis para este calendário.'; + + @override + String get calendarShareFreeBusy => 'Apenas livre/ocupado'; + + @override + String get calendarShareLimitedRead => 'Ler detalhes limitados'; + + @override + String get calendarShareRead => 'Ler todos os detalhes'; + + @override + String get calendarShareWrite => 'Editar eventos'; + + @override + String get calendarShareWriteWithoutPrivate => 'Editar sem detalhes privados'; + + @override + String get calendarShareOwner => 'Proprietário'; + + @override + String get eventLabel => 'Etiqueta do evento'; + + @override + String get loadOutlookCategories => 'Carregar categorias do Outlook'; + + @override + String get outlookCategoriesUnavailable => + 'As categorias do Outlook não estão disponíveis; as atribuições existentes são mantidas.'; + + @override + String get googleEventType => 'Tipo de evento'; + + @override + String get googleRegularEvent => 'Evento normal'; + + @override + String get googleFocusTime => 'Tempo de concentração'; + + @override + String get googleOutOfOffice => 'Fora do escritório'; + + @override + String get googleWorkingLocation => 'Local de trabalho'; + + @override + String get googleDeclineInvitations => 'Recusar convites sobrepostos'; + + @override + String get googleDeclineNone => 'Não recusar'; + + @override + String get googleDeclineNew => 'Recusar novos convites'; + + @override + String get googleDeclineAll => 'Recusar todos os convites em conflito'; + + @override + String get googleDeclineMessage => 'Mensagem de recusa'; + + @override + String get googleChatStatus => 'Estado da conversa'; + + @override + String get googleChatAvailable => 'Disponível'; + + @override + String get googleChatDoNotDisturb => 'Não incomodar'; + + @override + String get googleWorkAtHome => 'Casa'; + + @override + String get googleWorkAtOffice => 'Escritório'; + + @override + String get googleWorkAtCustomLocation => 'Local personalizado'; + + @override + String get googleWorkLocationLabel => 'Nome do local'; + + @override + String get googleStatusPrimaryOnly => + 'Os eventos de estado do Google exigem o calendário principal do Google.'; + + @override + String unknownEventLabel(String id) { + return 'Etiqueta desconhecida ($id)'; + } + + @override + String get calendarOwnerEmail => 'E-mail do proprietário do calendário'; } diff --git a/lib/l10n/generated/app_localizations_ru.dart b/lib/l10n/generated/app_localizations_ru.dart index 704f6dc3..6035adca 100644 --- a/lib/l10n/generated/app_localizations_ru.dart +++ b/lib/l10n/generated/app_localizations_ru.dart @@ -543,6 +543,18 @@ class AppLocalizationsRu extends AppLocalizations { @override String get openInProvider => 'Открыть в сервисе'; + @override + String get linkedResources => 'Связанные ресурсы'; + + @override + String get noLinkedResources => 'Нет связанных ресурсов'; + + @override + String get attachments => 'Вложения'; + + @override + String get attachmentsNotLoaded => 'Вложения не загружены'; + @override String get hideFromSchedule => 'Скрыть из расписания'; @@ -2020,6 +2032,10 @@ class AppLocalizationsRu extends AppLocalizations { return 'Удалить «$title»?'; } + @override + String get deleteAssignedTaskWarning => + 'Это также удалит исходную задачу в Google Документах или пространствах Chat.'; + @override String get metadata => 'Метаданные'; @@ -2652,6 +2668,15 @@ class AppLocalizationsRu extends AppLocalizations { @override String get removeAction => 'Удалить'; + @override + String removeOpenedSharedCalendarConfirmation(String title) { + return 'Убрать «$title» из BusyMax? Календарь владельца и его события не будут удалены.'; + } + + @override + String get attachmentUploadUnresolved => + 'Загрузка могла завершиться. Обновите список вложений, чтобы проверить результат перед повторной загрузкой.'; + @override String removeCalendarConfirmation(String title) { return 'Удалить «$title» из вашего списка Google Календаря? Общий календарь и его события удалены не будут.'; @@ -2991,7 +3016,17 @@ class AppLocalizationsRu extends AppLocalizations { String get searchDate => 'Дата'; @override - String get searchAnyDate => 'Любая дата'; + String get searchAnyDate => 'Любая дата (загруженные данные)'; + + @override + String get eventLink => 'Ссылка на событие'; + + @override + String get eventLinkOpenFailed => 'Не удалось открыть ссылку.'; + + @override + String get scheduleRangeIncomplete => + 'Не удалось проверить некоторые события. Показаны загруженные данные.'; @override String get searchThisWeek => 'Эта неделя'; @@ -3028,4 +3063,128 @@ class AppLocalizationsRu extends AppLocalizations { @override String get searchClearText => 'Очистить текст'; + + @override + String get openSharedCalendar => 'Открыть общий календарь'; + + @override + String get manageCalendarSharing => 'Управление доступом к календарю'; + + @override + String get shareRecipientEmail => 'Адрес получателя'; + + @override + String get shareRole => 'Роль доступа'; + + @override + String get addCalendarShare => 'Добавить доступ'; + + @override + String get sharingRefreshFailed => + 'Доступ изменён, но список разрешений не обновился. Обновите его перед следующим изменением.'; + + @override + String get sharingRateLimited => + 'Общий доступ временно ограничен. Повторите попытку после окончания ожидания.'; + + @override + String get attachmentUploadRateLimited => + 'Загрузка вложений временно ограничена. Повторите попытку после окончания ожидания.'; + + @override + String get sharingPermissionUnavailable => + 'Разрешения на общий доступ к этому календарю недоступны.'; + + @override + String get calendarShareFreeBusy => 'Только занятость'; + + @override + String get calendarShareLimitedRead => 'Просмотр ограниченных сведений'; + + @override + String get calendarShareRead => 'Просмотр всех сведений'; + + @override + String get calendarShareWrite => 'Изменение событий'; + + @override + String get calendarShareWriteWithoutPrivate => + 'Изменение без личных сведений'; + + @override + String get calendarShareOwner => 'Владелец'; + + @override + String get eventLabel => 'Метка события'; + + @override + String get loadOutlookCategories => 'Загрузить категории Outlook'; + + @override + String get outlookCategoriesUnavailable => + 'Категории Outlook недоступны; существующие назначения сохранены.'; + + @override + String get googleEventType => 'Тип события'; + + @override + String get googleRegularEvent => 'Обычное событие'; + + @override + String get googleFocusTime => 'Время концентрации'; + + @override + String get googleOutOfOffice => 'Вне офиса'; + + @override + String get googleWorkingLocation => 'Рабочее место'; + + @override + String get googleDeclineInvitations => 'Отклонять пересекающиеся приглашения'; + + @override + String get googleDeclineNone => 'Не отклонять'; + + @override + String get googleDeclineNew => 'Отклонять новые приглашения'; + + @override + String get googleDeclineAll => 'Отклонять все пересекающиеся приглашения'; + + @override + String get googleDeclineMessage => 'Сообщение об отклонении'; + + @override + String get googleChatStatus => 'Статус чата'; + + @override + String get googleChatAvailable => 'Доступен'; + + @override + String get googleChatDoNotDisturb => 'Не беспокоить'; + + @override + String get googleWorkAtHome => 'Дома'; + + @override + String get googleWorkAtOffice => 'В офисе'; + + @override + String get googleWorkAtCustomLocation => 'Другое место'; + + @override + String get googleWorkLocationLabel => 'Название места'; + + @override + String get googleStatusPrimaryOnly => + 'Для событий статуса Google нужен основной календарь Google.'; + + @override + String unknownEventLabel(String id) { + return 'Неизвестная метка ($id)'; + } + + @override + String get calendarOwnerEmail => + 'Адрес электронной почты владельца календаря'; } diff --git a/lib/l10n/generated/app_localizations_vi.dart b/lib/l10n/generated/app_localizations_vi.dart index a069e749..65041f12 100644 --- a/lib/l10n/generated/app_localizations_vi.dart +++ b/lib/l10n/generated/app_localizations_vi.dart @@ -543,6 +543,18 @@ class AppLocalizationsVi extends AppLocalizations { @override String get openInProvider => 'Mở trong dịch vụ'; + @override + String get linkedResources => 'Tài nguyên liên kết'; + + @override + String get noLinkedResources => 'Không có tài nguyên liên kết'; + + @override + String get attachments => 'Tệp đính kèm'; + + @override + String get attachmentsNotLoaded => 'Chưa tải tệp đính kèm'; + @override String get hideFromSchedule => 'Ẩn khỏi lịch biểu'; @@ -2002,6 +2014,10 @@ class AppLocalizationsVi extends AppLocalizations { return 'Xóa “$title”?'; } + @override + String get deleteAssignedTaskWarning => + 'Thao tác này cũng xóa công việc gốc trong Google Tài liệu hoặc không gian Chat.'; + @override String get metadata => 'Siêu dữ liệu'; @@ -2628,6 +2644,15 @@ class AppLocalizationsVi extends AppLocalizations { @override String get removeAction => 'Xóa'; + @override + String removeOpenedSharedCalendarConfirmation(String title) { + return 'Xóa “$title” khỏi BusyMax? Lịch và các sự kiện của chủ sở hữu sẽ không bị xóa.'; + } + + @override + String get attachmentUploadUnresolved => + 'Tệp có thể đã được tải lên. Làm mới danh sách tệp đính kèm để kiểm tra trước khi tải lên lại.'; + @override String removeCalendarConfirmation(String title) { return 'Xóa “$title” khỏi danh sách Google Calendar của bạn? Lịch dùng chung và các sự kiện trong đó sẽ không bị xóa.'; @@ -2964,7 +2989,17 @@ class AppLocalizationsVi extends AppLocalizations { String get searchDate => 'Ngày'; @override - String get searchAnyDate => 'Mọi ngày'; + String get searchAnyDate => 'Mọi ngày (dữ liệu đã tải)'; + + @override + String get eventLink => 'Liên kết sự kiện'; + + @override + String get eventLinkOpenFailed => 'Không thể mở liên kết.'; + + @override + String get scheduleRangeIncomplete => + 'Không thể kiểm tra một số sự kiện. Đang hiển thị dữ liệu đã tải.'; @override String get searchThisWeek => 'Tuần này'; @@ -3001,4 +3036,127 @@ class AppLocalizationsVi extends AppLocalizations { @override String get searchClearText => 'Xóa văn bản'; + + @override + String get openSharedCalendar => 'Mở lịch được chia sẻ'; + + @override + String get manageCalendarSharing => 'Quản lý chia sẻ lịch'; + + @override + String get shareRecipientEmail => 'Email người nhận'; + + @override + String get shareRole => 'Vai trò truy cập'; + + @override + String get addCalendarShare => 'Thêm quyền truy cập'; + + @override + String get sharingRefreshFailed => + 'Đã thay đổi chia sẻ nhưng không thể làm mới danh sách quyền. Hãy tải lại trước khi thay đổi tiếp.'; + + @override + String get sharingRateLimited => + 'Việc chia sẻ đang tạm thời bị giới hạn. Hãy thử lại sau khi hết thời gian chờ.'; + + @override + String get attachmentUploadRateLimited => + 'Việc tải tệp đính kèm lên đang tạm thời bị giới hạn. Hãy thử lại sau khi hết thời gian chờ.'; + + @override + String get sharingPermissionUnavailable => + 'Không có quyền chia sẻ cho lịch này.'; + + @override + String get calendarShareFreeBusy => 'Chỉ xem bận/rảnh'; + + @override + String get calendarShareLimitedRead => 'Đọc thông tin hạn chế'; + + @override + String get calendarShareRead => 'Đọc mọi thông tin'; + + @override + String get calendarShareWrite => 'Chỉnh sửa sự kiện'; + + @override + String get calendarShareWriteWithoutPrivate => + 'Chỉnh sửa không xem thông tin riêng tư'; + + @override + String get calendarShareOwner => 'Chủ sở hữu'; + + @override + String get eventLabel => 'Nhãn sự kiện'; + + @override + String get loadOutlookCategories => 'Tải danh mục Outlook'; + + @override + String get outlookCategoriesUnavailable => + 'Không có danh mục Outlook; các mục đã gán vẫn được giữ nguyên.'; + + @override + String get googleEventType => 'Loại sự kiện'; + + @override + String get googleRegularEvent => 'Sự kiện thường'; + + @override + String get googleFocusTime => 'Thời gian tập trung'; + + @override + String get googleOutOfOffice => 'Vắng mặt'; + + @override + String get googleWorkingLocation => 'Nơi làm việc'; + + @override + String get googleDeclineInvitations => 'Từ chối lời mời trùng lịch'; + + @override + String get googleDeclineNone => 'Không từ chối'; + + @override + String get googleDeclineNew => 'Từ chối lời mời mới'; + + @override + String get googleDeclineAll => 'Từ chối mọi lời mời trùng lịch'; + + @override + String get googleDeclineMessage => 'Lời nhắn từ chối'; + + @override + String get googleChatStatus => 'Trạng thái trò chuyện'; + + @override + String get googleChatAvailable => 'Có mặt'; + + @override + String get googleChatDoNotDisturb => 'Không làm phiền'; + + @override + String get googleWorkAtHome => 'Nhà'; + + @override + String get googleWorkAtOffice => 'Văn phòng'; + + @override + String get googleWorkAtCustomLocation => 'Địa điểm tùy chỉnh'; + + @override + String get googleWorkLocationLabel => 'Tên địa điểm'; + + @override + String get googleStatusPrimaryOnly => + 'Sự kiện trạng thái Google yêu cầu lịch Google chính của bạn.'; + + @override + String unknownEventLabel(String id) { + return 'Nhãn không xác định ($id)'; + } + + @override + String get calendarOwnerEmail => 'Email của chủ sở hữu lịch'; } diff --git a/lib/l10n/generated/app_localizations_zh.dart b/lib/l10n/generated/app_localizations_zh.dart index 35d44729..e46358ed 100644 --- a/lib/l10n/generated/app_localizations_zh.dart +++ b/lib/l10n/generated/app_localizations_zh.dart @@ -513,6 +513,18 @@ class AppLocalizationsZh extends AppLocalizations { @override String get openInProvider => '在服务中打开'; + @override + String get linkedResources => '关联资源'; + + @override + String get noLinkedResources => '没有关联资源'; + + @override + String get attachments => '附件'; + + @override + String get attachmentsNotLoaded => '附件未加载'; + @override String get hideFromSchedule => '从日程中隐藏'; @@ -1934,6 +1946,9 @@ class AppLocalizationsZh extends AppLocalizations { return '删除“$title”?'; } + @override + String get deleteAssignedTaskWarning => '这也会删除 Google 文档或 Chat 聊天室中的原始任务。'; + @override String get metadata => '元数据'; @@ -2540,6 +2555,14 @@ class AppLocalizationsZh extends AppLocalizations { @override String get removeAction => '移除'; + @override + String removeOpenedSharedCalendarConfirmation(String title) { + return '要从 BusyMax 移除“$title”吗?所有者的日历和日程不会被删除。'; + } + + @override + String get attachmentUploadUnresolved => '上传可能已完成。再次上传前,请刷新附件以确认结果。'; + @override String removeCalendarConfirmation(String title) { return '要从您的 Google 日历列表中移除“$title”吗?共享日历及其日程不会被删除。'; @@ -2868,7 +2891,16 @@ class AppLocalizationsZh extends AppLocalizations { String get searchDate => '日期'; @override - String get searchAnyDate => '任何日期'; + String get searchAnyDate => '任何日期(已下載資料)'; + + @override + String get eventLink => '活動連結'; + + @override + String get eventLinkOpenFailed => '無法開啟連結。'; + + @override + String get scheduleRangeIncomplete => '部分活動無法確認,正在顯示已下載資料。'; @override String get searchThisWeek => '本周'; @@ -2905,6 +2937,122 @@ class AppLocalizationsZh extends AppLocalizations { @override String get searchClearText => '清除文字'; + + @override + String get openSharedCalendar => '打开共享日历'; + + @override + String get manageCalendarSharing => '管理日历共享'; + + @override + String get shareRecipientEmail => '接收者邮箱'; + + @override + String get shareRole => '访问角色'; + + @override + String get addCalendarShare => '添加访问权限'; + + @override + String get sharingRefreshFailed => '共享更改已成功,但无法刷新权限列表。请在再次更改前重新加载列表。'; + + @override + String get sharingRateLimited => '共享暂时受到限制。请在等待时间结束后重试。'; + + @override + String get attachmentUploadRateLimited => '附件上传暂时受到限制。请在等待时间结束后重试。'; + + @override + String get sharingPermissionUnavailable => '此日历的共享权限不可用。'; + + @override + String get calendarShareFreeBusy => '仅查看忙闲'; + + @override + String get calendarShareLimitedRead => '读取有限详情'; + + @override + String get calendarShareRead => '读取全部详情'; + + @override + String get calendarShareWrite => '编辑日程'; + + @override + String get calendarShareWriteWithoutPrivate => '编辑但不查看私人详情'; + + @override + String get calendarShareOwner => '所有者'; + + @override + String get eventLabel => '日程标签'; + + @override + String get loadOutlookCategories => '加载 Outlook 类别'; + + @override + String get outlookCategoriesUnavailable => 'Outlook 类别不可用;现有分配会保留。'; + + @override + String get googleEventType => '日程类型'; + + @override + String get googleRegularEvent => '普通日程'; + + @override + String get googleFocusTime => '专注时间'; + + @override + String get googleOutOfOffice => '外出'; + + @override + String get googleWorkingLocation => '工作地点'; + + @override + String get googleDeclineInvitations => '拒绝重叠的邀请'; + + @override + String get googleDeclineNone => '不拒绝'; + + @override + String get googleDeclineNew => '拒绝新邀请'; + + @override + String get googleDeclineAll => '拒绝所有冲突邀请'; + + @override + String get googleDeclineMessage => '拒绝消息'; + + @override + String get googleChatStatus => '聊天状态'; + + @override + String get googleChatAvailable => '有空'; + + @override + String get googleChatDoNotDisturb => '勿扰'; + + @override + String get googleWorkAtHome => '家'; + + @override + String get googleWorkAtOffice => '办公室'; + + @override + String get googleWorkAtCustomLocation => '自定义地点'; + + @override + String get googleWorkLocationLabel => '地点名称'; + + @override + String get googleStatusPrimaryOnly => 'Google 状态日程需要您的 Google 主日历。'; + + @override + String unknownEventLabel(String id) { + return '未知标签($id)'; + } + + @override + String get calendarOwnerEmail => '日历所有者的电子邮件地址'; } /// The translations for Chinese, using the Han script (`zh_Hans`). @@ -3412,6 +3560,18 @@ class AppLocalizationsZhHans extends AppLocalizationsZh { @override String get openInProvider => '在服务中打开'; + @override + String get linkedResources => '关联资源'; + + @override + String get noLinkedResources => '没有关联资源'; + + @override + String get attachments => '附件'; + + @override + String get attachmentsNotLoaded => '附件未加载'; + @override String get hideFromSchedule => '从日程中隐藏'; @@ -4833,6 +4993,9 @@ class AppLocalizationsZhHans extends AppLocalizationsZh { return '删除“$title”?'; } + @override + String get deleteAssignedTaskWarning => '这也会删除 Google 文档或 Chat 聊天室中的原始任务。'; + @override String get metadata => '元数据'; @@ -5439,6 +5602,14 @@ class AppLocalizationsZhHans extends AppLocalizationsZh { @override String get removeAction => '移除'; + @override + String removeOpenedSharedCalendarConfirmation(String title) { + return '要从 BusyMax 移除“$title”吗?所有者的日历和日程不会被删除。'; + } + + @override + String get attachmentUploadUnresolved => '上传可能已完成。再次上传前,请刷新附件以确认结果。'; + @override String removeCalendarConfirmation(String title) { return '要从您的 Google 日历列表中移除“$title”吗?共享日历及其日程不会被删除。'; @@ -5767,7 +5938,16 @@ class AppLocalizationsZhHans extends AppLocalizationsZh { String get searchDate => '日期'; @override - String get searchAnyDate => '任何日期'; + String get searchAnyDate => '任何日期(已下载数据)'; + + @override + String get eventLink => '日程链接'; + + @override + String get eventLinkOpenFailed => '无法打开链接。'; + + @override + String get scheduleRangeIncomplete => '部分日程无法核实,正在显示已下载数据。'; @override String get searchThisWeek => '本周'; @@ -5804,6 +5984,122 @@ class AppLocalizationsZhHans extends AppLocalizationsZh { @override String get searchClearText => '清除文字'; + + @override + String get openSharedCalendar => '打开共享日历'; + + @override + String get manageCalendarSharing => '管理日历共享'; + + @override + String get shareRecipientEmail => '接收者邮箱'; + + @override + String get shareRole => '访问角色'; + + @override + String get addCalendarShare => '添加访问权限'; + + @override + String get sharingRefreshFailed => '共享更改已成功,但无法刷新权限列表。请在再次更改前重新加载列表。'; + + @override + String get sharingRateLimited => '共享暂时受到限制。请在等待时间结束后重试。'; + + @override + String get attachmentUploadRateLimited => '附件上传暂时受到限制。请在等待时间结束后重试。'; + + @override + String get sharingPermissionUnavailable => '此日历的共享权限不可用。'; + + @override + String get calendarShareFreeBusy => '仅查看忙闲'; + + @override + String get calendarShareLimitedRead => '读取有限详情'; + + @override + String get calendarShareRead => '读取全部详情'; + + @override + String get calendarShareWrite => '编辑日程'; + + @override + String get calendarShareWriteWithoutPrivate => '编辑但不查看私人详情'; + + @override + String get calendarShareOwner => '所有者'; + + @override + String get eventLabel => '日程标签'; + + @override + String get loadOutlookCategories => '加载 Outlook 类别'; + + @override + String get outlookCategoriesUnavailable => 'Outlook 类别不可用;现有分配会保留。'; + + @override + String get googleEventType => '日程类型'; + + @override + String get googleRegularEvent => '普通日程'; + + @override + String get googleFocusTime => '专注时间'; + + @override + String get googleOutOfOffice => '外出'; + + @override + String get googleWorkingLocation => '工作地点'; + + @override + String get googleDeclineInvitations => '拒绝重叠的邀请'; + + @override + String get googleDeclineNone => '不拒绝'; + + @override + String get googleDeclineNew => '拒绝新邀请'; + + @override + String get googleDeclineAll => '拒绝所有冲突邀请'; + + @override + String get googleDeclineMessage => '拒绝消息'; + + @override + String get googleChatStatus => '聊天状态'; + + @override + String get googleChatAvailable => '有空'; + + @override + String get googleChatDoNotDisturb => '勿扰'; + + @override + String get googleWorkAtHome => '家'; + + @override + String get googleWorkAtOffice => '办公室'; + + @override + String get googleWorkAtCustomLocation => '自定义地点'; + + @override + String get googleWorkLocationLabel => '地点名称'; + + @override + String get googleStatusPrimaryOnly => 'Google 状态日程需要您的 Google 主日历。'; + + @override + String unknownEventLabel(String id) { + return '未知标签($id)'; + } + + @override + String get calendarOwnerEmail => '日历所有者的电子邮件地址'; } /// The translations for Chinese, using the Han script (`zh_Hant`). @@ -6311,6 +6607,18 @@ class AppLocalizationsZhHant extends AppLocalizationsZh { @override String get openInProvider => '在服務中開啟'; + @override + String get linkedResources => '關聯資源'; + + @override + String get noLinkedResources => '沒有關聯資源'; + + @override + String get attachments => '附件'; + + @override + String get attachmentsNotLoaded => '附件尚未載入'; + @override String get hideFromSchedule => '從行程中隱藏'; @@ -7733,6 +8041,9 @@ class AppLocalizationsZhHant extends AppLocalizationsZh { return '要刪除「$title」嗎?'; } + @override + String get deleteAssignedTaskWarning => '這也會刪除 Google 文件或 Chat 聊天室中的原始工作。'; + @override String get metadata => '中繼資料'; @@ -8339,6 +8650,14 @@ class AppLocalizationsZhHant extends AppLocalizationsZh { @override String get removeAction => '移除'; + @override + String removeOpenedSharedCalendarConfirmation(String title) { + return '要從 BusyMax 移除「$title」嗎?擁有者的日曆及活動不會被刪除。'; + } + + @override + String get attachmentUploadUnresolved => '上傳可能已完成。再次上傳前,請重新整理附件以確認結果。'; + @override String removeCalendarConfirmation(String title) { return '要從您的 Google 日曆清單移除「$title」嗎?共用日曆及其活動不會被刪除。'; @@ -8667,7 +8986,16 @@ class AppLocalizationsZhHant extends AppLocalizationsZh { String get searchDate => '日期'; @override - String get searchAnyDate => '任何日期'; + String get searchAnyDate => '任何日期(已下載資料)'; + + @override + String get eventLink => '活動連結'; + + @override + String get eventLinkOpenFailed => '無法開啟連結。'; + + @override + String get scheduleRangeIncomplete => '部分活動無法確認,正在顯示已下載資料。'; @override String get searchThisWeek => '本週'; @@ -8704,4 +9032,120 @@ class AppLocalizationsZhHant extends AppLocalizationsZh { @override String get searchClearText => '清除文字'; + + @override + String get openSharedCalendar => '開啟共用行事曆'; + + @override + String get manageCalendarSharing => '管理行事曆共用'; + + @override + String get shareRecipientEmail => '收件者電子郵件'; + + @override + String get shareRole => '存取角色'; + + @override + String get addCalendarShare => '新增存取權'; + + @override + String get sharingRefreshFailed => '共用設定已變更,但無法重新整理權限清單。再次變更前請重新載入清單。'; + + @override + String get sharingRateLimited => '共用暫時受到限制。請在等待時間結束後重試。'; + + @override + String get attachmentUploadRateLimited => '附件上傳暫時受到限制。請在等待時間結束後重試。'; + + @override + String get sharingPermissionUnavailable => '此行事曆的共用權限無法使用。'; + + @override + String get calendarShareFreeBusy => '僅顯示忙閒'; + + @override + String get calendarShareLimitedRead => '讀取有限詳情'; + + @override + String get calendarShareRead => '讀取全部詳情'; + + @override + String get calendarShareWrite => '編輯行事曆事件'; + + @override + String get calendarShareWriteWithoutPrivate => '編輯但不顯示私人詳情'; + + @override + String get calendarShareOwner => '擁有者'; + + @override + String get eventLabel => '事件標籤'; + + @override + String get loadOutlookCategories => '載入 Outlook 類別'; + + @override + String get outlookCategoriesUnavailable => 'Outlook 類別無法使用;現有指派會保留。'; + + @override + String get googleEventType => '事件類型'; + + @override + String get googleRegularEvent => '一般事件'; + + @override + String get googleFocusTime => '專注時間'; + + @override + String get googleOutOfOffice => '不在辦公室'; + + @override + String get googleWorkingLocation => '工作地點'; + + @override + String get googleDeclineInvitations => '拒絕重疊邀請'; + + @override + String get googleDeclineNone => '不要拒絕'; + + @override + String get googleDeclineNew => '拒絕新邀請'; + + @override + String get googleDeclineAll => '拒絕所有衝突邀請'; + + @override + String get googleDeclineMessage => '拒絕訊息'; + + @override + String get googleChatStatus => '聊天狀態'; + + @override + String get googleChatAvailable => '有空'; + + @override + String get googleChatDoNotDisturb => '零打擾'; + + @override + String get googleWorkAtHome => '家中'; + + @override + String get googleWorkAtOffice => '辦公室'; + + @override + String get googleWorkAtCustomLocation => '自訂地點'; + + @override + String get googleWorkLocationLabel => '地點名稱'; + + @override + String get googleStatusPrimaryOnly => 'Google 狀態事件需要您的 Google 主要行事曆。'; + + @override + String unknownEventLabel(String id) { + return '未知標籤($id)'; + } + + @override + String get calendarOwnerEmail => '行事曆擁有者的電子郵件地址'; } diff --git a/lib/src/android/android_authorization.dart b/lib/src/android/android_authorization.dart index b8052756..998aaf86 100644 --- a/lib/src/android/android_authorization.dart +++ b/lib/src/android/android_authorization.dart @@ -12,6 +12,7 @@ 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 '../calendar_providers/calendar_provider_capabilities.dart'; import '../providers/busy_provider.dart'; const _googleScopes = [ @@ -28,14 +29,39 @@ const _microsoftNativeScopes = [ 'Calendars.ReadWrite', ]; +const _microsoftSharedNativeScope = 'Calendars.ReadWrite.Shared'; +const _microsoftCategoryNativeScope = 'MailboxSettings.Read'; + const _microsoftStoredScopes = { 'https://graph.microsoft.com/User.Read', 'https://graph.microsoft.com/Tasks.ReadWrite', 'https://graph.microsoft.com/Calendars.ReadWrite', }; +String? microsoftTenantIdFromAuthority(String? authority) { + final uri = Uri.tryParse(authority ?? ''); + if (uri == null || + uri.scheme != 'https' || + uri.host.toLowerCase() != 'login.microsoftonline.com' || + uri.pathSegments.isEmpty) { + return null; + } + final segment = uri.pathSegments.first.toLowerCase(); + if (segment == 'consumers') return microsoftPersonalTenantId; + return RegExp( + r'^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$', + ).hasMatch(segment) + ? segment + : null; +} + final class AndroidAuthorizationBroker - implements OAuthGateway, MicrosoftOAuthGateway, AccountTokenBroker { + implements + OAuthGateway, + MicrosoftOAuthGateway, + MicrosoftSharedCalendarAuthorization, + MicrosoftCategoryAuthorization, + AccountTokenBroker { AndroidAuthorizationBroker({ required BusyMaxAndroidPlatform platform, required http.Client httpClient, @@ -121,12 +147,122 @@ final class AndroidAuthorizationBroker accountId: accountId, tokenSet: tokenSet, user: user, + tenantId: microsoftTenantIdFromAuthority(native.authority), ); } on PlatformException catch (error) { throw _oauthError(error, provider: 'Microsoft'); } } + @override + Future authorizeSharedCalendarAccess(String accountId) async { + if (!accountId.startsWith('microsoft:')) { + throw const OAuthException( + 'MicrosoftOAuthSharedAccountMismatch', + 'Select a Microsoft account to open a shared calendar.', + ); + } + try { + await microsoftSharedCalendarAuthorizationHeader(accountId); + return; + } on OAuthException { + // The user requested this operation, so an interactive incremental + // consent request is appropriate when silent acquisition is unavailable. + } + try { + var keepCategoryScope = false; + try { + await microsoftCategoryAuthorizationHeader(accountId); + keepCategoryScope = true; + } on OAuthException { + // Category lookup is independently optional. + } + final native = await _platform.authorizeMicrosoftInteractively( + scopes: [ + ..._microsoftNativeScopes, + _microsoftSharedNativeScope, + if (keepCategoryScope) _microsoftCategoryNativeScope, + ], + ); + final tokenSet = _tokenSet(native); + final user = await _microsoftMe(native.accessToken); + if ('microsoft:${user.id}' != accountId || + !tokenSet.scopes.contains(microsoftSharedCalendarScope) || + (keepCategoryScope && + !tokenSet.scopes.contains(microsoftCategoryScope))) { + throw const OAuthException( + 'MicrosoftOAuthSharedConsentDenied', + 'Shared-calendar access was not granted for this account.', + ); + } + _requireSilentScopes(BusyProvider.microsoft, tokenSet); + await _platform.bindAuthorization( + provider: BusyProvider.microsoft.storageValue, + accountId: accountId, + nativeAccountId: native.nativeAccountId, + username: native.username ?? user.mail ?? user.userPrincipalName, + authority: native.authority, + ); + _lastAccessTokens[accountId] = native.accessToken; + } on PlatformException catch (error) { + throw _oauthError(error, provider: 'Microsoft'); + } + } + + @override + Future authorizeCategoryAccess(String accountId) async { + if (!accountId.startsWith('microsoft:')) { + throw const OAuthException( + 'MicrosoftOAuthCategoryAccountMismatch', + 'Select a Microsoft account to load categories.', + ); + } + try { + await microsoftCategoryAuthorizationHeader(accountId); + return; + } on OAuthException { + // This user-requested lookup can ask for optional incremental consent. + } + var keepSharedScope = false; + try { + await microsoftSharedCalendarAuthorizationHeader(accountId); + keepSharedScope = true; + } on OAuthException { + // Shared-calendar access is independently optional. + } + try { + final native = await _platform.authorizeMicrosoftInteractively( + scopes: [ + ..._microsoftNativeScopes, + if (keepSharedScope) _microsoftSharedNativeScope, + _microsoftCategoryNativeScope, + ], + ); + final tokenSet = _tokenSet(native); + final user = await _microsoftMe(native.accessToken); + if ('microsoft:${user.id}' != accountId || + !tokenSet.scopes.contains(microsoftCategoryScope) || + (keepSharedScope && + !tokenSet.scopes.contains(microsoftSharedCalendarScope))) { + throw const OAuthException( + 'MicrosoftOAuthCategoryConsentDenied', + 'Outlook category access was not granted for this account.', + ); + } + _requireSilentScopes(BusyProvider.microsoft, tokenSet); + await _platform.bindAuthorization( + provider: BusyProvider.microsoft.storageValue, + accountId: accountId, + nativeAccountId: native.nativeAccountId, + username: native.username ?? user.mail ?? user.userPrincipalName, + authority: native.authority, + ); + _lastAccessTokens[accountId] = native.accessToken; + } on PlatformException catch (error) { + throw _oauthError(error, provider: 'Microsoft'); + } + } + @override Future fetchUserInfo(OAuthTokenSet tokenSet) async { final response = await _httpClient.get( @@ -187,6 +323,52 @@ final class AndroidAuthorizationBroker } } + @override + Future microsoftSharedCalendarAuthorizationHeader( + String accountId, + ) async { + try { + final native = await _platform.authorizeMicrosoftSilently( + accountId: accountId, + scopes: [..._microsoftNativeScopes, _microsoftSharedNativeScope], + ); + final tokenSet = _tokenSet(native); + _requireSilentScopes(BusyProvider.microsoft, tokenSet); + if (!tokenSet.scopes.contains(microsoftSharedCalendarScope)) { + throw const OAuthException( + 'MicrosoftOAuthSharedConsentRequired', + 'Shared-calendar permission must be granted for this account.', + ); + } + _lastAccessTokens[accountId] = native.accessToken; + return 'Bearer ${native.accessToken}'; + } on PlatformException catch (error) { + throw _silentOAuthError(error, provider: BusyProvider.microsoft); + } + } + + @override + Future microsoftCategoryAuthorizationHeader(String accountId) async { + try { + final native = await _platform.authorizeMicrosoftSilently( + accountId: accountId, + scopes: [..._microsoftNativeScopes, _microsoftCategoryNativeScope], + ); + final tokenSet = _tokenSet(native); + _requireSilentScopes(BusyProvider.microsoft, tokenSet); + if (!tokenSet.scopes.contains(microsoftCategoryScope)) { + throw const OAuthException( + 'MicrosoftOAuthCategoryConsentRequired', + 'Outlook category lookup requires optional mailbox-settings consent.', + ); + } + _lastAccessTokens[accountId] = native.accessToken; + return 'Bearer ${native.accessToken}'; + } on PlatformException catch (error) { + throw _silentOAuthError(error, provider: BusyProvider.microsoft); + } + } + @override Future recoverUnauthorized( BusyProvider provider, @@ -307,7 +489,11 @@ final class AndroidAuthorizationBroker String _storedScope(String value) { final scope = value.trim(); - for (final nativeScope in _microsoftNativeScopes) { + for (final nativeScope in [ + ..._microsoftNativeScopes, + _microsoftSharedNativeScope, + _microsoftCategoryNativeScope, + ]) { if (scope.toLowerCase() == nativeScope.toLowerCase()) { return 'https://graph.microsoft.com/$nativeScope'; } diff --git a/lib/src/android/presentation/android_availability_dialog.dart b/lib/src/android/presentation/android_availability_dialog.dart index 785da5b7..eea32d92 100644 --- a/lib/src/android/presentation/android_availability_dialog.dart +++ b/lib/src/android/presentation/android_availability_dialog.dart @@ -19,6 +19,7 @@ Future showAndroidGuestAvailabilityDialog( required String accountId, required BusyProvider provider, required EventEditorDraft draft, + String? calendarTimeZone, String? collectionId, }) => showDialog( context: context, @@ -27,6 +28,7 @@ Future showAndroidGuestAvailabilityDialog( provider: provider, collectionId: collectionId, draft: draft, + calendarTimeZone: calendarTimeZone, ), ); @@ -36,6 +38,7 @@ class AndroidGuestAvailabilityDialog extends ConsumerStatefulWidget { required this.accountId, required this.provider, required this.draft, + this.calendarTimeZone, this.collectionId, }); @@ -43,6 +46,7 @@ class AndroidGuestAvailabilityDialog extends ConsumerStatefulWidget { final BusyProvider provider; final String? collectionId; final EventEditorDraft draft; + final String? calendarTimeZone; @override ConsumerState createState() => @@ -54,7 +58,8 @@ class _AndroidGuestAvailabilityDialogState bool _loading = true; Object? _error; List _nextcloud = const []; - Map _google = const {}; + Map _cloud = const {}; + int _loadGeneration = 0; List get _recipients => widget.draft.attendees .where((attendee) => !attendee.self && !attendee.organizer) @@ -69,41 +74,61 @@ class _AndroidGuestAvailabilityDialogState unawaited(_load()); } + @override + void didUpdateWidget(covariant AndroidGuestAvailabilityDialog oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.accountId != widget.accountId || + oldWidget.provider != widget.provider || + oldWidget.collectionId != widget.collectionId || + oldWidget.calendarTimeZone != widget.calendarTimeZone || + oldWidget.draft != widget.draft) { + unawaited(_load()); + } + } + Future _load() async { + final generation = ++_loadGeneration; if (!_loading && mounted) setState(() => _loading = true); _error = null; + _nextcloud = const []; + _cloud = const {}; try { if (widget.provider == BusyProvider.nextcloud) { final collectionId = widget.collectionId; if (collectionId == null) throw StateError('Missing collection'); - _nextcloud = await ref + final results = await ref .read(nextcloudSchedulingServiceProvider(widget.accountId)) .freeBusyForDraft( collectionId: collectionId, draft: widget.draft, fallbackTimeZone: ref.read(localTimeZoneProvider), ); - } else if (widget.provider == BusyProvider.google) { + if (generation != _loadGeneration || !mounted) return; + _nextcloud = results; + } else if (widget.provider == BusyProvider.google || + widget.provider == BusyProvider.microsoft) { final client = ref.read( calendarRemoteApiClientForAccountProvider(widget.accountId), ); - final start = widget.draft.start; - final end = widget.draft.end; - if (client == null || start == null || end == null) { + final interval = widget.draft.cloudAvailabilityInterval( + calendarTimeZone: widget.calendarTimeZone, + ); + if (client == null || interval == 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(), + rangeStart: interval.start.toUtc(), + rangeEnd: interval.end.toUtc(), ); final byCalendar = { for (final result in results) result.calendarId.toLowerCase(): result, }; - _google = { + if (generation != _loadGeneration || !mounted) return; + _cloud = { for (final recipient in _recipients) recipient: byCalendar[recipient.toLowerCase()] ?? @@ -113,7 +138,7 @@ class _AndroidGuestAvailabilityDialogState ), }; } else { - _google = { + _cloud = { for (final recipient in _recipients) recipient: FreeBusyCalendarResultDto( calendarId: recipient, @@ -125,9 +150,11 @@ class _AndroidGuestAvailabilityDialogState throw StateError('Availability is unsupported'); } } on Object catch (error) { - _error = error; + if (generation == _loadGeneration) _error = error; } finally { - if (mounted) setState(() => _loading = false); + if (mounted && generation == _loadGeneration) { + setState(() => _loading = false); + } } } @@ -166,7 +193,7 @@ class _AndroidGuestAvailabilityDialogState interval(value.startUtc, value.endUtc), ], ), - for (final entry in _google.entries) + for (final entry in _cloud.entries) _AvailabilityEntry( recipient: entry.key, free: diff --git a/lib/src/android/presentation/android_cloud_calendar_sharing_content.dart b/lib/src/android/presentation/android_cloud_calendar_sharing_content.dart new file mode 100644 index 00000000..e18d852b --- /dev/null +++ b/lib/src/android/presentation/android_cloud_calendar_sharing_content.dart @@ -0,0 +1,383 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../app/app_bootstrap.dart'; +import '../../l10n/l10n.dart'; +import '../../providers/busy_provider.dart'; +import '../../features/calendar/data/calendar_repository.dart'; +import '../../features/calendar/data/cloud_calendar_sharing_service.dart'; +import '../../features/calendar/presentation/cloud_calendar_sharing_labels.dart'; + +/// Provider-specific permission management, hosted by the Linux and Android +/// native dialog shells. It never edits local sharing state on API failure. +class CloudCalendarSharingContent extends ConsumerStatefulWidget { + const CloudCalendarSharingContent({ + super.key, + required this.sources, + this.serviceFactory, + }); + + final List sources; + final CloudCalendarSharingService Function(CalendarSourceEntity)? + serviceFactory; + + @override + ConsumerState createState() => + _CloudCalendarSharingContentState(); +} + +class _CloudCalendarSharingContentState + extends ConsumerState { + final _recipient = TextEditingController(); + CloudCalendarSharingService? _service; + CloudCalendarShareSnapshot? _snapshot; + Object? _error; + bool _busy = false; + Timer? _cooldownTimer; + String? _newRole; + int _generation = 0; + + @override + void initState() { + super.initState(); + if (widget.sources.isNotEmpty) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) unawaited(_select(widget.sources.first)); + }); + } + } + + @override + void didUpdateWidget(covariant CloudCalendarSharingContent oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.sources.length != widget.sources.length || + oldWidget.sources.any( + (source) => !widget.sources.any((current) => current.id == source.id), + )) { + final selected = _service?.source; + final replacement = widget.sources.where( + (source) => source.id == selected?.id, + ); + final next = replacement.isNotEmpty + ? replacement.first + : widget.sources.isEmpty + ? null + : widget.sources.first; + if (next == null) { + _generation++; + _service = null; + _snapshot = null; + } else { + unawaited(_select(next)); + } + } + } + + @override + void dispose() { + _generation++; + _cooldownTimer?.cancel(); + _recipient.dispose(); + super.dispose(); + } + + Future _select(CalendarSourceEntity source) async { + final generation = ++_generation; + _cooldownTimer?.cancel(); + setState(() { + _service = null; + _snapshot = null; + _error = null; + _busy = true; + }); + try { + final service = + widget.serviceFactory?.call(source) ?? + CloudCalendarSharingService( + source: source, + google: source.provider == BusyProvider.google + ? ref.read( + googleCalendarApiClientForAccountProvider(source.accountId), + ) + : null, + microsoft: source.provider == BusyProvider.microsoft + ? ref.read( + microsoftCalendarApiClientForAccountProvider( + source.accountId, + ), + ) + : null, + ); + if (!mounted || generation != _generation) return; + setState(() { + _service = service; + _newRole = service.newGrantRoles.first; + }); + final snapshot = await service.load(); + if (!mounted || generation != _generation) return; + setState(() => _snapshot = snapshot); + } on Object catch (error) { + if (!mounted || generation != _generation) return; + setState(() => _error = error); + } finally { + if (mounted && generation == _generation) { + setState(() => _busy = false); + _scheduleCooldown(); + } + } + } + + Future _mutate( + Future Function(CloudCalendarSharingService) + action, + ) async { + final service = _service; + if (service == null || + _busy || + _snapshot == null || + _snapshot!.refreshError != null || + _snapshot!.outcomeUnknown || + service.hasUnresolvedOutcome) { + return; + } + final generation = _generation; + setState(() { + _busy = true; + _error = null; + }); + try { + final snapshot = await action(service); + if (!mounted || generation != _generation) return; + setState(() => _snapshot = snapshot); + if (snapshot.outcomeUnknown) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.nextcloudOutcomeUnknown)), + ); + } else if (snapshot.refreshError != null) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.sharingRefreshFailed)), + ); + } + } on Object catch (error) { + if (!mounted || generation != _generation) return; + setState(() => _error = error); + } finally { + if (mounted && generation == _generation) { + setState(() => _busy = false); + _scheduleCooldown(); + } + } + } + + void _scheduleCooldown() { + _cooldownTimer?.cancel(); + final service = _service; + final delay = service?.retryAfter; + if (service == null || delay == null) return; + final generation = _generation; + _cooldownTimer = Timer(delay + const Duration(milliseconds: 1), () { + if (!mounted || + generation != _generation || + !identical(_service, service)) { + return; + } + setState(() {}); + _scheduleCooldown(); + }); + } + + @override + Widget build(BuildContext context) { + final service = _service; + final grants = _snapshot?.grants; + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (widget.sources.length > 1) + DropdownButtonFormField( + key: const Key('calendar-sharing-source'), + initialValue: service?.source.id, + items: [ + for (final source in widget.sources) + DropdownMenuItem(value: source.id, child: Text(source.summary)), + ], + onChanged: _busy + ? null + : (id) { + if (id == null) return; + unawaited( + _select(widget.sources.firstWhere((e) => e.id == id)), + ); + }, + ), + if (_busy) const LinearProgressIndicator(), + if (_error != null) ...[ + SelectableText('${context.l10n.operationFailed}: $_error'), + TextButton( + onPressed: service == null || _busy + ? null + : () => unawaited(_select(service.source)), + child: Text(context.l10n.retry), + ), + ], + if (service?.isRateLimited == true) + Text(context.l10n.sharingRateLimited), + if (_snapshot?.outcomeUnknown == true) ...[ + Text(context.l10n.nextcloudOutcomeUnknown), + TextButton( + onPressed: _busy || service == null + ? null + : () => unawaited(_select(service.source)), + child: Text(context.l10n.retry), + ), + ] else if (_snapshot?.refreshError != null) ...[ + Text(context.l10n.sharingRefreshFailed), + TextButton( + onPressed: _busy || service == null + ? null + : () => unawaited(_select(service.source)), + child: Text(context.l10n.retry), + ), + ], + if (grants != null) ...[ + ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 290), + child: ListView( + shrinkWrap: true, + children: [ + for (final grant in grants) + ListTile( + key: Key('calendar-sharing-grant-${grant.id}'), + title: Text(grant.recipient), + subtitle: Text( + calendarSharingRoleLabel(context.l10n, grant.role), + ), + trailing: Wrap( + spacing: 4, + children: [ + if (grant.canChange) + PopupMenuButton( + enabled: + !_busy && + service?.isRateLimited != true && + _snapshot?.refreshError == null && + _snapshot?.outcomeUnknown != true, + tooltip: context.l10n.shareRole, + onSelected: (role) => unawaited( + _mutate((s) => s.change(grant, role)), + ), + itemBuilder: (_) => [ + for (final role in grant.allowedRoles) + PopupMenuItem( + value: role, + child: Text( + calendarSharingRoleLabel( + context.l10n, + role, + ), + ), + ), + ], + icon: const Icon(Icons.edit_outlined), + ), + if (grant.canRevoke) + IconButton( + tooltip: context.l10n.nextcloudRevokeShare, + onPressed: + _busy || + service?.isRateLimited == true || + _snapshot?.refreshError != null || + _snapshot?.outcomeUnknown == true + ? null + : () => unawaited(_confirmRevoke(grant)), + icon: const Icon(Icons.person_remove_outlined), + ), + ], + ), + ), + ], + ), + ), + const SizedBox(height: 12), + TextField( + key: const Key('calendar-sharing-recipient'), + controller: _recipient, + enabled: !_busy && _snapshot?.outcomeUnknown != true, + keyboardType: TextInputType.emailAddress, + decoration: InputDecoration( + labelText: context.l10n.shareRecipientEmail, + ), + ), + DropdownButtonFormField( + key: ValueKey('calendar-sharing-new-role-${service!.source.id}'), + initialValue: _newRole, + decoration: InputDecoration(labelText: context.l10n.shareRole), + items: [ + for (final role in service.newGrantRoles) + DropdownMenuItem( + value: role, + child: Text(calendarSharingRoleLabel(context.l10n, role)), + ), + ], + onChanged: _busy || _snapshot?.outcomeUnknown == true + ? null + : (role) => setState(() => _newRole = role), + ), + Align( + alignment: Alignment.centerRight, + child: TextButton.icon( + key: const Key('calendar-sharing-add'), + onPressed: + _busy || + service.isRateLimited || + _newRole == null || + _snapshot?.refreshError != null || + _snapshot?.outcomeUnknown == true + ? null + : () => unawaited(_add()), + icon: const Icon(Icons.person_add_alt_1_outlined), + label: Text(context.l10n.addCalendarShare), + ), + ), + ], + if (service == null && widget.sources.isEmpty) + Text(context.l10n.sharingPermissionUnavailable), + ], + ); + } + + Future _add() async { + final role = _newRole; + if (role == null) return; + await _mutate( + (service) => service.add(recipient: _recipient.text, role: role), + ); + if (_error == null && mounted) _recipient.clear(); + } + + Future _confirmRevoke(CloudCalendarShareGrant grant) async { + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(context.l10n.nextcloudRevokeShare), + content: Text(grant.recipient), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: Text(context.l10n.cancel), + ), + TextButton( + onPressed: () => Navigator.pop(context, true), + child: Text(context.l10n.nextcloudRevokeShare), + ), + ], + ), + ); + if (confirmed == true && mounted) { + await _mutate((service) => service.revoke(grant)); + } + } +} diff --git a/lib/src/android/presentation/android_event_attachments_dialog.dart b/lib/src/android/presentation/android_event_attachments_dialog.dart new file mode 100644 index 00000000..ea0c706e --- /dev/null +++ b/lib/src/android/presentation/android_event_attachments_dialog.dart @@ -0,0 +1,537 @@ +import 'dart:typed_data'; + +import 'package:busymax_android_platform/busymax_android_platform.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../app/app_bootstrap.dart'; +import '../../features/schedule/presentation/attachment_download.dart'; +import '../../features/schedule/presentation/attachment_upload_coordinator.dart'; +import '../../features/schedule/presentation/google_event_attachment_reference.dart'; +import '../../features/schedule/presentation/schedule_event_details_format.dart'; +import '../../l10n/l10n.dart'; +import '../../microsoft_calendar/microsoft_event_attachment.dart'; +import '../../providers/busy_provider.dart'; +import '../../schedule/event_attachment_link.dart'; +import '../../schedule/schedule_item.dart'; + +Future showAndroidEventAttachmentsDialog( + BuildContext context, + CalendarScheduleItem item, +) => showDialog( + context: context, + builder: (_) => _AndroidEventAttachmentsDialog(item: item), +); + +class _AndroidEventAttachmentsDialog extends ConsumerStatefulWidget { + const _AndroidEventAttachmentsDialog({required this.item}); + final CalendarScheduleItem item; + + @override + ConsumerState<_AndroidEventAttachmentsDialog> createState() => + _AndroidEventAttachmentsDialogState(); +} + +class _AndroidEventAttachmentsDialogState + extends ConsumerState<_AndroidEventAttachmentsDialog> { + bool _saving = false; + late List _referenceLinks; + + @override + void initState() { + super.initState(); + _referenceLinks = List.of(widget.item.attachmentLinks); + } + + @override + Widget build(BuildContext context) { + final item = widget.item; + final eventId = item.providerEventId; + final microsoft = item.provider == BusyProvider.microsoft; + final google = item.provider == BusyProvider.google; + final nextcloud = item.provider == BusyProvider.nextcloud; + final uploads = ref.watch(attachmentUploadCoordinatorProvider); + final uploadKey = eventId == null + ? null + : AttachmentUploadCoordinator.eventKey( + item.accountId, + item.providerCalendarId, + eventId, + ); + final result = !microsoft || eventId == null + ? null + : ref.watch( + microsoftEventAttachmentsProvider(( + accountId: item.accountId, + calendarId: item.providerCalendarId, + eventId: eventId, + )), + ); + return AlertDialog( + title: Text(context.l10n.attachments), + content: SizedBox( + width: 420, + child: !microsoft + ? _referenceLinks.isEmpty + ? Text( + item.attachmentsLoaded + ? context.l10n.noneValue + : context.l10n.attachmentsNotLoaded, + ) + : ListView( + shrinkWrap: true, + children: [ + for (final link in _referenceLinks) + ListTile( + title: Text(link.name), + trailing: + (google || nextcloud) && + item.capabilities.canEdit + ? IconButton( + key: const Key( + 'event-attachment-remove-reference', + ), + tooltip: context.l10n.delete, + onPressed: _saving + ? null + : () => _removeReference(link), + icon: const Icon(Icons.delete_outline), + ) + : null, + onTap: () => _open(link.url), + ), + ], + ) + : result == null + ? Text(context.l10n.attachmentsNotLoaded) + : result.when( + loading: () => const LinearProgressIndicator(), + error: (error, _) => SelectableText( + '${context.l10n.attachmentsNotLoaded}\n$error', + ), + data: (attachments) => attachments.isEmpty + ? Text(context.l10n.noneValue) + : ListView( + shrinkWrap: true, + children: [ + for (final attachment in attachments) + ListTile( + title: Text(attachment.name), + subtitle: attachment.size == null + ? null + : Text('${attachment.size} B'), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (attachment.canDownload) + IconButton( + tooltip: context.l10n.save, + onPressed: _saving + ? null + : () => _save(attachment), + icon: const Icon(Icons.download_outlined), + ), + if (item.capabilities.canEdit && + attachment.id.isNotEmpty) + IconButton( + tooltip: context.l10n.delete, + onPressed: _saving + ? null + : () => _remove(attachment), + icon: const Icon(Icons.delete_outline), + ), + ], + ), + onTap: + attachment.kind == + MicrosoftEventAttachmentKind + .reference && + attachment.sourceUrl != null + ? () => _open(attachment.sourceUrl!) + : null, + ), + ], + ), + ), + ), + actions: [ + if (microsoft && + uploadKey != null && + uploads.retryAfter(uploadKey) != null) + Text(context.l10n.attachmentUploadRateLimited), + if (microsoft && + uploadKey != null && + uploads.needsReconciliation(uploadKey)) ...[ + Text( + uploads.confirmedId(uploadKey) == null + ? context.l10n.attachmentUploadUnresolved + : context.l10n.completed, + ), + TextButton( + onPressed: _saving ? null : () => _reconcileUpload(eventId!), + child: Text(context.l10n.refresh), + ), + if (uploads.hasResumableSession(uploadKey)) + TextButton( + onPressed: _saving ? null : () => _cancelUpload(eventId!), + child: Text(context.l10n.cancel), + ), + if (uploads.canResolveManually(uploadKey)) ...[ + TextButton( + onPressed: _saving + ? null + : () => _resolveUpload(uploadKey, exists: true), + child: Text(context.l10n.completed), + ), + TextButton( + onPressed: _saving + ? null + : () => _resolveUpload(uploadKey, exists: false), + child: Text(context.l10n.retry), + ), + ], + ], + if ((microsoft || google || nextcloud) && + item.capabilities.canEdit && + (nextcloud || eventId != null)) + TextButton.icon( + key: const Key('event-attachment-add'), + onPressed: + _saving || + (microsoft && + uploadKey != null && + !uploads.canSubmit(uploadKey)) + ? null + : _add, + icon: const Icon(Icons.add), + label: Text(context.l10n.attachments), + ), + TextButton( + onPressed: _saving ? null : () => Navigator.pop(context), + child: Text(context.l10n.close), + ), + ], + ); + } + + void _refresh() { + final eventId = widget.item.providerEventId; + if (eventId == null) return; + ref.invalidate( + microsoftEventAttachmentsProvider(( + accountId: widget.item.accountId, + calendarId: widget.item.providerCalendarId, + eventId: eventId, + )), + ); + } + + Future _add() async { + final eventId = widget.item.providerEventId; + if (!widget.item.capabilities.canEdit) return; + if (widget.item.provider == BusyProvider.google || + widget.item.provider == BusyProvider.nextcloud) { + var enteredUrl = ''; + final url = await showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: Text(context.l10n.attachments), + content: TextField( + onChanged: (value) => enteredUrl = value, + autofocus: true, + keyboardType: TextInputType.url, + decoration: InputDecoration(labelText: context.l10n.webLink), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext), + child: Text(context.l10n.cancel), + ), + TextButton( + onPressed: () => Navigator.pop(dialogContext, enteredUrl), + child: Text(context.l10n.save), + ), + ], + ), + ); + if (url == null || !mounted) return; + await _changeReference(addFileUrl: url); + return; + } + if (eventId == null) return; + final document = await BusyMaxAndroidPlatform.instance.openDocument( + mimeTypes: const ['*/*'], + maximumBytes: 150 * 1024 * 1024, + ); + if (document == null || !mounted) return; + final name = safeAttachmentFileName(document.name ?? ''); + if (name == null) { + _error(const FormatException('Invalid attachment name.')); + return; + } + setState(() => _saving = true); + try { + await ref + .read(attachmentUploadCoordinatorProvider) + .uploadEvent( + client: ref.read( + microsoftCalendarApiClientForAccountProvider( + widget.item.accountId, + ), + ), + accountId: widget.item.accountId, + calendarId: widget.item.providerCalendarId, + eventId: eventId, + name: name, + contentType: document.mimeType ?? 'application/octet-stream', + bytes: document.bytes, + ); + _refresh(); + } on Object catch (error) { + _refresh(); + _error(error); + } finally { + if (mounted) setState(() => _saving = false); + } + } + + Future _reconcileUpload(String eventId) async { + setState(() => _saving = true); + try { + await ref + .read(attachmentUploadCoordinatorProvider) + .reconcileEvent( + client: ref.read( + microsoftCalendarApiClientForAccountProvider( + widget.item.accountId, + ), + ), + accountId: widget.item.accountId, + calendarId: widget.item.providerCalendarId, + eventId: eventId, + ); + _refresh(); + } on Object catch (error) { + _error(error); + } finally { + if (mounted) setState(() => _saving = false); + } + } + + Future _cancelUpload(String eventId) async { + setState(() => _saving = true); + try { + await ref + .read(attachmentUploadCoordinatorProvider) + .cancelEvent( + client: ref.read( + microsoftCalendarApiClientForAccountProvider( + widget.item.accountId, + ), + ), + accountId: widget.item.accountId, + calendarId: widget.item.providerCalendarId, + eventId: eventId, + ); + _refresh(); + } on Object catch (error) { + _error(error); + } finally { + if (mounted) setState(() => _saving = false); + } + } + + Future _resolveUpload( + AttachmentUploadKey key, { + required bool exists, + }) async { + final confirmed = await showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: Text(exists ? context.l10n.completed : context.l10n.retry), + content: Text(context.l10n.attachmentUploadUnresolved), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext, false), + child: Text(context.l10n.cancel), + ), + FilledButton( + onPressed: () => Navigator.pop(dialogContext, true), + child: Text(exists ? context.l10n.completed : context.l10n.retry), + ), + ], + ), + ); + if (confirmed != true || !mounted) return; + ref + .read(attachmentUploadCoordinatorProvider) + .resolveUncertainManually(key, exists: exists); + _refresh(); + } + + Future _removeReference(EventAttachmentLink link) async { + final confirmed = await showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: Text(context.l10n.delete), + content: Text(link.name), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext, false), + child: Text(context.l10n.cancel), + ), + TextButton( + onPressed: () => Navigator.pop(dialogContext, true), + child: Text(context.l10n.delete), + ), + ], + ), + ); + if (confirmed != true || !mounted) return; + await _changeReference(removeFileUrl: link.url); + } + + Future _changeReference({ + String? addFileUrl, + String? removeFileUrl, + }) async { + setState(() => _saving = true); + try { + final List links; + var cacheUpdated = true; + if (widget.item.provider == BusyProvider.google) { + final changed = await changeGoogleEventAttachmentReference( + item: widget.item, + client: ref.read( + googleCalendarApiClientForAccountProvider(widget.item.accountId), + ), + repository: ref.read(calendarRepositoryProvider), + addFileUrl: addFileUrl, + removeFileUrl: removeFileUrl, + ); + links = changed.links; + cacheUpdated = changed.cacheUpdated; + } else { + final detail = await ref + .read(calendarRepositoryProvider) + .changeNextcloudUriAttachmentReference( + accountId: widget.item.accountId, + eventId: widget.item.id, + addUrl: addFileUrl, + removeUrl: removeFileUrl, + ); + links = eventAttachmentLinks(detail?.attachments); + cacheUpdated = detail != null; + } + if (!mounted) return; + setState(() => _referenceLinks = links); + if (!cacheUpdated) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(context.l10n.refreshFailed(context.l10n.attachments)), + ), + ); + } + } on Object catch (error) { + _error(error); + } finally { + if (mounted) setState(() => _saving = false); + } + } + + Future _remove(MicrosoftEventAttachment attachment) async { + final eventId = widget.item.providerEventId; + if (eventId == null || !widget.item.capabilities.canEdit) return; + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(context.l10n.delete), + content: Text(attachment.name), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: Text(context.l10n.cancel), + ), + TextButton( + onPressed: () => Navigator.pop(context, true), + child: Text(context.l10n.delete), + ), + ], + ), + ); + if (confirmed != true || !mounted) return; + setState(() => _saving = true); + try { + await ref + .read( + microsoftCalendarApiClientForAccountProvider(widget.item.accountId), + ) + .deleteEventAttachment( + calendarId: widget.item.providerCalendarId, + eventId: eventId, + attachmentId: attachment.id, + ); + ref + .read(attachmentUploadCoordinatorProvider) + .attachmentRemoved( + AttachmentUploadCoordinator.eventKey( + widget.item.accountId, + widget.item.providerCalendarId, + eventId, + ), + attachment.id, + ); + _refresh(); + } on Object catch (error) { + _refresh(); + _error(error); + } finally { + if (mounted) setState(() => _saving = false); + } + } + + Future _open(String url) async { + if (await openScheduleWebLink(url) || !mounted) return; + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(context.l10n.eventLinkOpenFailed))); + } + + Future _save(MicrosoftEventAttachment attachment) async { + final name = safeAttachmentFileName(attachment.name); + if (name == null) { + _error(const FormatException('Invalid attachment name.')); + return; + } + final eventId = widget.item.providerEventId; + if (eventId == null) return; + setState(() => _saving = true); + try { + final bytes = await ref + .read( + microsoftCalendarApiClientForAccountProvider(widget.item.accountId), + ) + .downloadEventAttachment( + calendarId: widget.item.providerCalendarId, + eventId: eventId, + attachment: attachment, + ); + await BusyMaxAndroidPlatform.instance.createDocument( + suggestedName: name, + mimeType: attachment.contentType ?? 'application/octet-stream', + bytes: Uint8List.fromList(bytes), + ); + } on Object catch (error) { + _error(error); + } finally { + if (mounted) setState(() => _saving = false); + } + } + + void _error(Object error) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.exportFailed('$error'))), + ); + } +} diff --git a/lib/src/android/presentation/android_recurrence_dialog.dart b/lib/src/android/presentation/android_recurrence_dialog.dart new file mode 100644 index 00000000..e7b33072 --- /dev/null +++ b/lib/src/android/presentation/android_recurrence_dialog.dart @@ -0,0 +1,290 @@ +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; + +import '../../features/recurrence/domain/event_recurrence_codec.dart'; +import '../../features/recurrence/domain/recurrence_rule.dart'; +import '../../l10n/l10n.dart'; + +enum _RecurrenceEnd { never, date, count } + +/// Material editor for the same provider-safe recurrence subset used on desktop. +/// Unsupported source rules are shown as such and remain untouched until the +/// user deliberately selects a new frequency. +Future showAndroidRecurrenceDialog( + BuildContext context, { + required RecurrenceRule initial, + required DateTime baseDate, + required bool allDay, + required String? timeZone, + required RecurrenceRuleLimits limits, + required String providerLabel, +}) { + var rule = initial; + var end = rule.count != null + ? _RecurrenceEnd.count + : rule.untilRaw != null + ? _RecurrenceEnd.date + : _RecurrenceEnd.never; + var until = DateTime.tryParse(rule.untilDateFor(timeZone: timeZone) ?? ''); + var intervalInputValid = true; + var countInputValid = true; + return showDialog( + context: context, + builder: (dialogContext) => StatefulBuilder( + builder: (context, setState) { + final l10n = context.l10n; + final valid = + rule.isSupported && + limits.supports(rule) && + intervalInputValid && + countInputValid; + return AlertDialog( + title: Text(l10n.repeat), + content: SizedBox( + width: 420, + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (!rule.isSupported) + Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Text( + l10n.unsupportedRecurrencePreserved, + style: TextStyle( + color: Theme.of(context).colorScheme.error, + ), + ), + ), + DropdownButtonFormField( + isExpanded: true, + key: ValueKey(rule.frequency), + initialValue: rule.isSupported ? rule.frequency : null, + decoration: InputDecoration(labelText: l10n.repeat), + items: [ + for (final frequency in RecurrenceFrequency.values) + DropdownMenuItem( + value: frequency, + child: Text(switch (frequency) { + RecurrenceFrequency.none => l10n.repeatNone, + RecurrenceFrequency.daily => l10n.repeatDaily, + RecurrenceFrequency.weekly => l10n.repeatWeekly, + RecurrenceFrequency.monthly => l10n.repeatMonthly, + RecurrenceFrequency.yearly => l10n.repeatYearly, + }), + ), + ], + onChanged: (frequency) { + if (frequency == null) return; + setState(() { + if (rule.isSupported && frequency == rule.frequency) { + return; + } + rule = androidRuleForFrequency(frequency, baseDate); + end = _RecurrenceEnd.never; + intervalInputValid = true; + countInputValid = true; + }); + }, + ), + if (rule.isSupported && rule.repeats) ...[ + const SizedBox(height: 12), + TextFormField( + key: ValueKey( + 'android-recurrence-interval-${rule.frequency}', + ), + initialValue: '${rule.interval}', + decoration: InputDecoration(labelText: l10n.repeatEvery), + keyboardType: TextInputType.number, + onChanged: (text) { + final value = int.tryParse(text); + setState(() { + intervalInputValid = value != null && value > 0; + if (intervalInputValid) { + rule = rule.copyWith(interval: value); + } + }); + }, + ), + if (rule.frequency == RecurrenceFrequency.weekly) ...[ + const SizedBox(height: 12), + Text(l10n.repeatOn), + Wrap( + spacing: 6, + children: [ + for ( + var index = 0; + index < rfcWeekdays.length; + index++ + ) + FilterChip( + label: Text( + DateFormat.E( + Localizations.localeOf( + context, + ).toLanguageTag(), + ).format(DateTime(2024, 1, index + 1)), + ), + selected: rule.byDay.contains(rfcWeekdays[index]), + onSelected: (selected) => setState(() { + final days = [...rule.byDay]; + if (selected) { + days.add(rfcWeekdays[index]); + } else if (days.length > 1) { + days.remove(rfcWeekdays[index]); + } + days.sort( + (a, b) => rfcWeekdays + .indexOf(a) + .compareTo(rfcWeekdays.indexOf(b)), + ); + rule = rule.copyWith(byDay: days); + }), + ), + ], + ), + ], + const SizedBox(height: 12), + DropdownButtonFormField<_RecurrenceEnd>( + isExpanded: true, + initialValue: end, + decoration: InputDecoration(labelText: l10n.repeatEnd), + items: [ + DropdownMenuItem( + value: _RecurrenceEnd.never, + child: Text(l10n.repeatNever), + ), + DropdownMenuItem( + value: _RecurrenceEnd.date, + child: Text(l10n.repeatUntil), + ), + DropdownMenuItem( + value: _RecurrenceEnd.count, + child: Text(l10n.repeatAfter), + ), + ], + onChanged: (selection) { + if (selection == null) return; + setState(() { + end = selection; + countInputValid = true; + rule = switch (selection) { + _RecurrenceEnd.never => rule.copyWith( + count: null, + untilRaw: null, + ), + _RecurrenceEnd.date => + rule + .copyWith(count: null) + .withUntilDate( + _dateValue(until ?? baseDate), + allDay: allDay, + baseDate: baseDate, + timeZone: timeZone, + ), + _RecurrenceEnd.count => rule.copyWith( + count: rule.count ?? 10, + untilRaw: null, + ), + }; + }); + }, + ), + if (end == _RecurrenceEnd.date) + TextButton.icon( + icon: const Icon(Icons.calendar_today_outlined), + label: Text( + DateFormat.yMd( + Localizations.localeOf(context).toLanguageTag(), + ).format(until ?? baseDate), + ), + onPressed: () async { + final date = await showDatePicker( + context: context, + initialDate: (until ?? baseDate).isBefore(baseDate) + ? baseDate + : until ?? baseDate, + firstDate: baseDate, + lastDate: DateTime(9999, 12, 31), + ); + if (date == null) return; + setState(() { + until = date; + rule = rule.withUntilDate( + _dateValue(date), + allDay: allDay, + baseDate: baseDate, + timeZone: timeZone, + ); + }); + }, + ), + if (end == _RecurrenceEnd.count) + TextFormField( + key: ValueKey( + 'android-recurrence-count-${rule.frequency}', + ), + initialValue: '${rule.count ?? 10}', + decoration: InputDecoration( + labelText: l10n.repeatCount, + ), + keyboardType: TextInputType.number, + onChanged: (text) { + final value = int.tryParse(text); + setState(() { + countInputValid = value != null && value > 0; + if (countInputValid) { + rule = rule.copyWith(count: value); + } + }); + }, + ), + ], + if (rule.isSupported && !valid) + Padding( + padding: const EdgeInsets.only(top: 12), + child: Text( + l10n.recurrenceUnsupportedByProvider(providerLabel), + ), + ), + ], + ), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext), + child: Text(l10n.cancel), + ), + FilledButton( + onPressed: valid + ? () => Navigator.pop(dialogContext, rule) + : null, + child: Text(l10n.save), + ), + ], + ); + }, + ), + ); +} + +RecurrenceRule androidRuleForFrequency( + RecurrenceFrequency frequency, + DateTime base, +) => const RecurrenceRule.none().copyWith( + frequency: frequency, + byDay: frequency == RecurrenceFrequency.weekly + ? [rfcWeekdays[base.weekday - 1]] + : const [], + byMonthDay: + frequency == RecurrenceFrequency.monthly || + frequency == RecurrenceFrequency.yearly + ? [base.day] + : const [], + byMonth: frequency == RecurrenceFrequency.yearly ? [base.month] : const [], +); + +String _dateValue(DateTime date) => + '${date.year.toString().padLeft(4, '0')}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}'; diff --git a/lib/src/android/presentation/android_schedule_screen.dart b/lib/src/android/presentation/android_schedule_screen.dart index b33c9dc2..030fcc71 100644 --- a/lib/src/android/presentation/android_schedule_screen.dart +++ b/lib/src/android/presentation/android_schedule_screen.dart @@ -11,18 +11,25 @@ import '../../app/app_bootstrap.dart'; import '../../app/common/busymax_motion_widgets.dart'; import '../../app/common/busymax_mutation_list.dart'; import '../../calendar_providers/calendar_mutation.dart'; +import '../../calendar_providers/calendar_provider_capabilities.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/calendar/domain/google_status_event.dart'; +import '../../features/calendar/presentation/google_status_event_labels.dart'; import '../../features/maps/application/external_location_launcher.dart'; import '../../features/schedule/application/saved_schedule_location.dart'; +import '../../features/schedule/presentation/schedule_event_details_format.dart'; +import '../../features/schedule/presentation/schedule_item_exporter.dart'; +import '../../features/schedule/presentation/cloud_calendar_series_export.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 '../../microsoft_calendar/microsoft_calendar_models.dart'; import '../../providers/busy_provider.dart'; import '../../schedule/schedule_filters.dart'; import '../../schedule/schedule_item.dart'; @@ -36,6 +43,8 @@ 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_event_attachments_dialog.dart'; +import 'android_recurrence_dialog.dart'; import 'android_date_picker.dart'; import 'android_settings_screen.dart'; import 'android_tasks_screen.dart'; @@ -145,6 +154,33 @@ class _AndroidScheduleScreenState extends ConsumerState { )), ); final searchCriteria = _searchCriteria; + final displayedRange = + searchCriteria?.range ?? + _rangeFor( + DateTime(_anchor.year, _anchor.month, _anchor.day), + mode, + firstWeekday: _firstWeekday(context), + ); + final coverageFilters = _searching && searchCriteria != null + ? searchCriteria.filters(_query) + : ScheduleFilters( + sourceIds: { + for (final source + in ref.watch(calendarSourcesStreamProvider).valueOrNull ?? + const []) + if (source.selected) source.id, + }, + sourceFilterActive: true, + ); + final bool? coverageComplete = + items.hasValue && searchCriteria?.date != ScheduleSearchDate.any + ? ref + .read(scheduleRepositoryProvider) + .cloudCoverageCompleteFor( + displayedRange, + filters: coverageFilters, + ) + : null; final presentation = _searching && searchCriteria != null ? _buildSearchPresentation( items: items, @@ -228,6 +264,24 @@ class _AndroidScheduleScreenState extends ConsumerState { visible: !_searching, child: _navigationRow(context, mode), ), + if (coverageComplete != true && + items.hasValue && + searchCriteria?.date != ScheduleSearchDate.any) + Card( + child: ListTile( + leading: Icon( + coverageComplete == null + ? Icons.sync + : Icons.cloud_off_outlined, + ), + title: Text( + coverageComplete == null + ? context.l10n.scheduleLoading + : context.l10n.scheduleRangeIncomplete, + ), + dense: true, + ), + ), Expanded( child: BusyMaxBinaryPresentation( alternateActive: _searching, @@ -654,7 +708,9 @@ class _AndroidScheduleScreenState extends ConsumerState { ), const SizedBox(height: 8), Text(item.sourceName ?? item.provider.displayName), - if (item.start != null) + if (item is CalendarScheduleItem) + Text(scheduleEventIntervalLabel(context, item)) + else if (item.start != null) Text( formatClockDateTime( context, @@ -662,6 +718,103 @@ class _AndroidScheduleScreenState extends ConsumerState { DateFormat.yMMMd().format(item.start!), ), ), + if (item is CalendarScheduleItem) ...[ + for (final line in googleStatusDetailLines( + context.l10n, + item.eventType, + item.googleStatusProperties, + )) + Text(line), + if (calendarEventDescription(item).trim().isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: 12), + child: SelectableText(calendarEventDescription(item)), + ), + if (item.organizer != null) + ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.person_outline), + title: Text( + '${context.l10n.organizer}: ' + '${scheduleEventPersonName(item.organizer)}', + ), + ), + if (item.currentUserResponse case final response?) + Text( + '${context.l10n.yourResponse}: ' + '${scheduleEventResponseLabel(context, response)}', + ), + for (final attendee in item.attendees) + ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.person_outline), + title: Text( + scheduleEventAttendeeLabel(context, attendee), + ), + ), + if (item.joinMeetingUrl case final meetingLink?) + FilledButton.icon( + onPressed: () { + Navigator.pop(sheetContext); + unawaited( + _openEventWebLink(meetingLink, meeting: true), + ); + }, + icon: const Icon(Icons.video_call_outlined), + label: Text(context.l10n.joinMeeting), + ), + if (item.eventLinkUrl case final eventLink?) + OutlinedButton.icon( + onPressed: () { + Navigator.pop(sheetContext); + unawaited(_openEventWebLink(eventLink)); + }, + icon: const Icon(Icons.open_in_new), + label: Text(context.l10n.eventLink), + ), + if (item.attachmentsMayExist || + item.attachmentLinks.isNotEmpty) + ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.attach_file), + title: Text(context.l10n.attachments), + ), + for (final attachment in item.attachmentLinks) + ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.open_in_new), + title: Text(attachment.name), + subtitle: attachment.mimeType == null + ? null + : Text(attachment.mimeType!), + onTap: () { + Navigator.pop(sheetContext); + unawaited(_openEventWebLink(attachment.url)); + }, + ), + if (item.attachmentsMayExist && !item.attachmentsLoaded) + Text(context.l10n.attachmentsNotLoaded), + if (item.provider == BusyProvider.microsoft && + item.providerEventId != null) + OutlinedButton.icon( + onPressed: () { + Navigator.pop(sheetContext); + unawaited( + showAndroidEventAttachmentsDialog(context, item), + ); + }, + icon: const Icon(Icons.attach_file), + label: Text(context.l10n.attachments), + ), + for (final uri in calendarEventDescriptionLinks(item)) + TextButton( + onPressed: () { + Navigator.pop(sheetContext); + unawaited(_openEventWebLink(uri.toString())); + }, + child: Text(uri.toString()), + ), + ], if (item case CalendarScheduleItem( :final location?, ) when location.isNotEmpty) @@ -683,6 +836,20 @@ class _AndroidScheduleScreenState extends ConsumerState { padding: const EdgeInsets.only(top: 12), child: Text(notes), ), + if (item is TaskScheduleItem) + for (final link in item.availableSourceLinks) + TextButton.icon( + onPressed: () { + Navigator.pop(sheetContext); + unawaited(_openEventWebLink(link.url)); + }, + icon: const Icon(Icons.open_in_new), + label: Text( + link.label?.isNotEmpty == true + ? link.label! + : context.l10n.openInProvider, + ), + ), if (item is CalendarScheduleItem && item.canRespondToInvitation) ...[ const SizedBox(height: 16), @@ -755,7 +922,8 @@ class _AndroidScheduleScreenState extends ConsumerState { : context.l10n.editTask, ), ), - if (item.provider == BusyProvider.nextcloud) ...[ + if (item is CalendarScheduleItem || + item.provider == BusyProvider.nextcloud) ...[ const SizedBox(height: 8), OutlinedButton.icon( onPressed: () => _exportItem(sheetContext, item), @@ -771,17 +939,82 @@ class _AndroidScheduleScreenState extends ConsumerState { ); } + Future _openEventWebLink(String value, {bool meeting = false}) async { + if (await openScheduleWebLink(value) || !mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + meeting + ? context.l10n.joinMeetingFailed + : context.l10n.eventLinkOpenFailed, + ), + ), + ); + } + 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; + String? raw; + if (item is CalendarScheduleItem) { + final repository = ref.read(calendarRepositoryProvider); + final detail = await repository.loadEventDetail(item.id); + if (!mounted || !context.mounted) return; + final recurring = + detail != null && + (detail.recurrence != null || + detail.providerRecurringEventId != null); + final series = + recurring && + canExportAuthoritativeEventSeries( + provider: item.provider, + providerEventId: item.providerEventId, + davCollectionId: detail.davCollectionId, + ) + ? await showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: Text(context.l10n.export), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext, false), + child: Text(context.l10n.singleOccurrence), + ), + TextButton( + onPressed: () => Navigator.pop(dialogContext, true), + child: Text(context.l10n.entireSeries), + ), + ], + ), + ) + : false; + if (series == null) return; + if (series && item.provider == BusyProvider.google) { + raw = await exportGoogleEventSeries( + client: ref.read( + googleCalendarApiClientForAccountProvider(item.accountId), + ), + calendarId: item.providerCalendarId, + eventId: item.providerEventId!, + nowUtc: DateTime.now().toUtc(), + ); + } else if (series && item.provider == BusyProvider.microsoft) { + raw = await exportMicrosoftEventSeries( + client: ref.read( + microsoftCalendarApiClientForAccountProvider(item.accountId), + ), + calendarId: item.providerCalendarId, + eventId: item.providerEventId!, + nowUtc: DateTime.now().toUtc(), + ); + } else if (series || !recurring) { + raw = await repository.nativeEventExport(item.id); + } + raw ??= scheduleItemToICalendar(item, nowUtc: DateTime.now().toUtc()); + } else if (item is TaskScheduleItem) { + raw = await ref + .read(tasksRepositoryForAccountProvider(item.accountId)) + .nativeTaskExport(item.sourceId, item.id); + } if (raw == null) return; final uri = await BusyMaxAndroidPlatform.instance.createDocument( suggestedName: item is TaskScheduleItem ? 'task.ics' : 'event.ics', @@ -1941,6 +2174,7 @@ class AndroidEventEditor extends ConsumerStatefulWidget { class _AndroidEventEditorState extends ConsumerState { late EventEditorDraft _draft = widget.draft; + late final String? _initialDescription = widget.draft.editableDescription; late final TextEditingController _title = TextEditingController( text: _draft.title, ); @@ -1948,7 +2182,7 @@ class _AndroidEventEditorState extends ConsumerState { text: _draft.location, ); late final TextEditingController _description = TextEditingController( - text: _draft.description, + text: _draft.editableDescription, ); late final TextEditingController _guests = TextEditingController( text: _draft.attendees @@ -1959,12 +2193,15 @@ class _AndroidEventEditorState extends ConsumerState { late final TextEditingController _categories = TextEditingController( text: _draft.categories.join(', '), ); + final Map _guestOptionalOverrides = {}; int? _reminderMinutes; - late RecurrenceFrequency _frequency = EventRecurrenceCodec.decode( + Object? _editedGoogleReminders; + List? _editedNextcloudReminders; + late RecurrenceRule _recurrenceRule = EventRecurrenceCodec.decode( _provider, _draft.recurrence, baseDate: _draft.start, - ).frequency; + ); bool _recurrenceChanged = false; bool _saving = false; bool _allowPop = false; @@ -2030,9 +2267,31 @@ class _AndroidEventEditorState extends ConsumerState { .map((attendee) => attendee.email) .join(', ') || _categories.text != widget.draft.categories.join(', ') || + _guestOptionalOverrides.isNotEmpty || _reminderMinutes != null || + _editedGoogleReminders != null || + _editedNextcloudReminders != null || _recurrenceChanged; + Future _editRecurrence() async { + final start = _draft.start; + if (start == null) return; + final result = await showAndroidRecurrenceDialog( + context, + initial: _recurrenceRule, + baseDate: start, + allDay: _draft.allDay, + timeZone: _draft.startTimeZone, + limits: EventRecurrenceCodec.limitsFor(_provider), + providerLabel: _provider.displayName, + ); + if (!mounted || result == null || result == _recurrenceRule) return; + setState(() { + _recurrenceRule = result; + _recurrenceChanged = true; + }); + } + @override Widget build(BuildContext context) { return PopScope( @@ -2081,6 +2340,7 @@ class _AndroidEventEditorState extends ConsumerState { ), const SizedBox(height: 12), DropdownButtonFormField( + isExpanded: true, initialValue: _source?.id, decoration: InputDecoration(labelText: context.l10n.calendar), items: [ @@ -2099,13 +2359,32 @@ class _AndroidEventEditorState extends ConsumerState { final source = widget.sources.firstWhere( (s) => s.id == id, ); + if (googleStatusEventTypes.contains(_draft.eventType) && + (source.provider != BusyProvider.google || + !source.primaryCalendar)) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + context.l10n.googleStatusPrimaryOnly, + ), + ), + ); + return; + } setState( () => _draft = _draft.copyWith( accountId: source.accountId, sourceId: source.id, providerCalendarId: source.providerCalendarId, - clearShowAs: true, - clearVisibilityOrSensitivity: true, + clearEventLabelId: _draft.sourceId != source.id, + eventLabelChanged: _draft.sourceId != source.id, + clearShowAs: !googleStatusEventTypes.contains( + _draft.eventType, + ), + clearVisibilityOrSensitivity: + !googleStatusEventTypes.contains( + _draft.eventType, + ), ), ); }, @@ -2114,7 +2393,10 @@ class _AndroidEventEditorState extends ConsumerState { contentPadding: EdgeInsets.zero, title: Text(context.l10n.allDay), value: _draft.allDay, - onChanged: !_canEdit + onChanged: + !_canEdit || + _draft.eventType == 'focusTime' || + _draft.eventType == 'outOfOffice' ? null : (value) => setState( () => _draft = _draft.copyWith(allDay: value), @@ -2156,7 +2438,9 @@ class _AndroidEventEditorState extends ConsumerState { child: Text(_eventAvailabilityLabel(context, value)), ), ], - onChanged: !_canEdit + onChanged: + !_canEdit || + googleStatusEventTypes.contains(_draft.eventType) ? null : (value) => setState( () => _draft = _draft.copyWith(showAs: value), @@ -2179,7 +2463,7 @@ class _AndroidEventEditorState extends ConsumerState { child: Text(_eventVisibilityLabel(context, value)), ), ], - onChanged: !_canEdit + onChanged: !_canEdit || _draft.eventType == 'workingLocation' ? null : (value) => setState( () => _draft = _draft.copyWith( @@ -2211,38 +2495,21 @@ class _AndroidEventEditorState extends ConsumerState { 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), + if (_draft.providerRecurringEventId == null) + ListTile( + key: const ValueKey('android-event-recurrence'), + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.repeat), + title: Text(context.l10n.repeat), + subtitle: Text( + _recurrenceRule.isSupported + ? _recurrenceRule.repeats + ? _recurrenceRule.toRrule() + : context.l10n.repeatNone + : context.l10n.unsupportedRecurrencePreserved, ), - ], - onChanged: !_canEdit - ? null - : (value) => setState(() { - _frequency = value ?? RecurrenceFrequency.none; - _recurrenceChanged = true; - }), - ), + onTap: _canEdit ? _editRecurrence : null, + ), if (_draft.providerRecurringEventId != null) ...[ const SizedBox(height: 12), ListTile( @@ -2258,32 +2525,197 @@ class _AndroidEventEditorState extends ConsumerState { ), ], 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]) + if (_provider == BusyProvider.google) + ..._googleReminderRows() + else if (_provider == BusyProvider.nextcloud) + ..._nextcloudReminderRows() + else ...[ + DropdownButtonFormField( + key: ValueKey('android-event-reminder-${_draft.sourceId}'), + initialValue: + _reminderMinutes ?? + androidEventReminderSelection( + _provider, + _draft.reminders, + ), + decoration: InputDecoration(labelText: context.l10n.reminder), + items: [ DropdownMenuItem( - value: value, - child: Text(context.l10n.reminderMinutesBefore(value)), + value: -2, + child: Text(context.l10n.noReminders), ), - ], - onChanged: !_canEdit - ? null - : (value) => setState(() => _reminderMinutes = value), - ), + DropdownMenuItem( + value: 0, + child: Text(context.l10n.reminderAtStart), + ), + for (final value in { + 5, + 10, + 30, + 60, + 1440, + if ((_reminderMinutes ?? + androidEventReminderSelection( + _provider, + _draft.reminders, + )) + case final int selected when selected > 0) + selected, + }) + DropdownMenuItem( + value: value, + child: Text(context.l10n.reminderMinutesBefore(value)), + ), + ], + onChanged: !_canEdit + ? null + : (value) => setState(() => _reminderMinutes = value), + ), + if (_reminderMinutes == null) + if (androidEventReminderMinutes(_provider, _draft.reminders) + case final List minutes) + Padding( + padding: const EdgeInsets.only(top: 8), + child: Text( + minutes.isEmpty + ? context.l10n.noReminders + : minutes + .map( + (minute) => minute == 0 + ? context.l10n.reminderAtStart + : context.l10n.reminderMinutesBefore( + minute, + ), + ) + .join(', '), + ), + ), + if (_reminderMinutes == null && + androidEventReminderSelection( + _provider, + _draft.reminders, + ) == + null && + androidEventReminderMinutes(_provider, _draft.reminders) == + null) + Padding( + padding: const EdgeInsets.only(top: 8), + child: Text(context.l10n.unsupportedReminder), + ), + ], + if (_draft.conference != null || + (_provider == BusyProvider.google && + (_source?.allowedConferenceSolutions.contains( + 'hangoutsMeet', + ) ?? + false)) || + (_provider == BusyProvider.microsoft && + (_source?.allowedConferenceSolutions.contains( + 'teamsForBusiness', + ) ?? + false))) + SwitchListTile( + key: const ValueKey('android-event-create-conference'), + contentPadding: EdgeInsets.zero, + title: Text( + _provider == BusyProvider.google + ? context.l10n.addGoogleMeet + : context.l10n.addTeamsMeeting, + ), + subtitle: _draft.conference == null + ? null + : Text(context.l10n.onlineMeetingAdded), + value: _draft.conference != null || _draft.createConference, + onChanged: _canEdit && _draft.conference == null + ? (value) => setState( + () => + _draft = _draft.copyWith(createConference: value), + ) + : null, + ), const SizedBox(height: 12), TextFormField( controller: _guests, enabled: _canEdit && _draft.canManageAttendees, - decoration: InputDecoration(labelText: context.l10n.guests), + decoration: InputDecoration( + labelText: context.l10n.guests, + hintText: context.l10n.addGuestEmail, + ), keyboardType: TextInputType.emailAddress, onChanged: (_) => setState(() {}), ), + for (final attendee in mergeAndroidEventAttendees( + widget.draft.attendees, + _guests.text, + ).attendees.where((a) => !a.self && !a.organizer)) + ListTile( + key: ValueKey('android-event-guest-${attendee.email}'), + contentPadding: EdgeInsets.zero, + title: Text(attendee.displayName ?? attendee.email), + subtitle: attendee.displayName == null + ? null + : Text(attendee.email), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + DropdownButton( + value: + _guestOptionalOverrides[attendee.email + .toLowerCase()] ?? + attendee.optional, + items: [ + DropdownMenuItem( + value: false, + child: Text(context.l10n.attendeeRequired), + ), + DropdownMenuItem( + value: true, + child: Text(context.l10n.attendeeOptional), + ), + ], + onChanged: _canEdit && _draft.canManageAttendees + ? (optional) => setState(() { + if (optional == null || + optional == attendee.optional) { + _guestOptionalOverrides.remove( + attendee.email.toLowerCase(), + ); + } else { + _guestOptionalOverrides[attendee.email + .toLowerCase()] = + optional; + } + }) + : null, + ), + IconButton( + tooltip: context.l10n.delete, + onPressed: _canEdit && _draft.canManageAttendees + ? () => setState(() { + _guests.text = + mergeAndroidEventAttendees( + widget.draft.attendees, + _guests.text, + ).attendees + .where( + (a) => + !a.self && + !a.organizer && + a.email.toLowerCase() != + attendee.email.toLowerCase(), + ) + .map((a) => a.email) + .join(', '); + _guestOptionalOverrides.remove( + attendee.email.toLowerCase(), + ); + }) + : null, + icon: const Icon(Icons.close), + ), + ], + ), + ), if (_canCheckGuestAvailability) ...[ const SizedBox(height: 4), Align( @@ -2302,6 +2734,14 @@ class _AndroidEventEditorState extends ConsumerState { decoration: InputDecoration(labelText: context.l10n.categories), onChanged: (_) => setState(() {}), ), + if (_provider == BusyProvider.microsoft && _source != null) + _microsoftCategoryField(_source!), + if (_provider == BusyProvider.google && + (_source?.primaryCalendar == true || + googleStatusEventTypes.contains(_draft.eventType))) + ..._googleStatusRows(), + if (_provider == BusyProvider.google && _source != null) + _googleEventLabelField(_source!), if (_canDelete) ...[ const SizedBox(height: 24), OutlinedButton.icon( @@ -2318,12 +2758,544 @@ class _AndroidEventEditorState extends ConsumerState { ); } + List _googleReminderRows() { + final value = _editedGoogleReminders ?? _draft.reminders; + final mode = androidGoogleReminderMode(value); + final popupRows = androidGooglePopupReminderRows(value); + return [ + DropdownButtonFormField( + key: ValueKey('android-event-reminder-mode-${_draft.sourceId}-$mode'), + initialValue: mode, + decoration: InputDecoration(labelText: context.l10n.reminders), + items: [ + DropdownMenuItem( + value: -1, + child: Text(context.l10n.defaultReminder), + ), + DropdownMenuItem(value: -2, child: Text(context.l10n.noReminders)), + DropdownMenuItem(value: -3, child: Text(context.l10n.reminders)), + ], + onChanged: !_canEdit + ? null + : (selection) { + if (selection == null) return; + setState( + () => _editedGoogleReminders = androidSetGoogleReminderMode( + value, + selection, + ), + ); + }, + ), + if (mode == -3) ...[ + for (final row in popupRows) + Row( + children: [ + Expanded( + child: DropdownButtonFormField( + key: ValueKey('android-event-popup-${row.index}'), + initialValue: row.minutes, + decoration: InputDecoration(labelText: context.l10n.reminder), + items: [ + for (final minute in { + 0, + 5, + 10, + 30, + 60, + 1440, + row.minutes, + }) + DropdownMenuItem( + value: minute, + child: Text( + minute == 0 + ? context.l10n.reminderAtStart + : context.l10n.reminderMinutesBefore(minute), + ), + ), + ], + onChanged: !_canEdit + ? null + : (minute) { + if (minute == null) return; + setState( + () => _editedGoogleReminders = + androidEditGooglePopupReminder( + value, + overrideIndex: row.index, + minutes: minute, + ), + ); + }, + ), + ), + IconButton( + tooltip: context.l10n.removeReminder, + onPressed: !_canEdit + ? null + : () => setState( + () => _editedGoogleReminders = + androidRemoveGooglePopupReminder( + value, + overrideIndex: row.index, + ), + ), + icon: const Icon(Icons.remove_circle_outline), + ), + ], + ), + if (androidGoogleReminderOverrideCount(value) < 5) + TextButton.icon( + onPressed: !_canEdit + ? null + : () => setState( + () => + _editedGoogleReminders = androidEditGooglePopupReminder( + value, + overrideIndex: null, + minutes: 10, + ), + ), + icon: const Icon(Icons.add), + label: Text(context.l10n.addReminder), + ), + if (androidGoogleHasUnsupportedReminder(value)) + Text(context.l10n.unsupportedReminder), + ], + ]; + } + + List _nextcloudReminderRows() { + final rows = + _editedNextcloudReminders ?? + androidNextcloudEditableReminderRows(_draft.reminders); + if (rows == null) return [Text(context.l10n.unsupportedReminder)]; + return [ + Text(context.l10n.reminders), + if (rows.isEmpty) Text(context.l10n.noReminders), + for (var index = 0; index < rows.length; index++) + Row( + children: [ + Expanded( + child: DropdownButtonFormField( + key: ValueKey('android-event-dav-reminder-$index'), + initialValue: rows[index].minutes, + decoration: InputDecoration(labelText: context.l10n.reminder), + items: [ + for (final minute in { + 0, + 5, + 10, + 30, + 60, + 1440, + rows[index].minutes, + }) + DropdownMenuItem( + value: minute, + child: Text( + minute == 0 + ? context.l10n.reminderAtStart + : context.l10n.reminderMinutesBefore(minute), + ), + ), + ], + onChanged: !_canEdit + ? null + : (minute) { + if (minute == null) return; + setState(() { + final edited = [...rows]; + edited[index] = edited[index].copyWith( + minutes: minute, + ); + _editedNextcloudReminders = edited; + }); + }, + ), + ), + IconButton( + tooltip: context.l10n.removeReminder, + icon: const Icon(Icons.remove_circle_outline), + onPressed: !_canEdit + ? null + : () => setState(() { + final edited = [...rows]..removeAt(index); + _editedNextcloudReminders = edited; + }), + ), + ], + ), + TextButton.icon( + onPressed: !_canEdit + ? null + : () => setState(() { + _editedNextcloudReminders = [ + ...rows, + const AndroidNextcloudReminderRow(minutes: 10), + ]; + }), + icon: const Icon(Icons.add), + label: Text(context.l10n.addReminder), + ), + if (androidNextcloudHasUnsupportedAlarms(_draft.reminders)) + Text(context.l10n.unsupportedReminder), + ]; + } + + List _googleStatusRows() { + final l10n = context.l10n; + final type = _draft.eventType ?? 'default'; + final types = ['default', ...googleStatusEventTypes]; + final rows = [ + DropdownButtonFormField( + key: const Key('android-google-event-type'), + isExpanded: true, + initialValue: types.contains(type) ? type : 'default', + decoration: InputDecoration(labelText: l10n.googleEventType), + items: [ + for (final value in types) + DropdownMenuItem( + value: value, + child: Text(googleEventTypeLabel(l10n, value)), + ), + ], + onChanged: !_canEdit || _draft.eventId != null + ? null + : (value) { + if (value == null) return; + final start = _draft.start; + setState( + () => _draft = _draft.copyWith( + eventType: value, + googleStatusProperties: defaultGoogleStatusProperties( + value, + ), + showAs: value == 'workingLocation' + ? 'transparent' + : 'opaque', + visibilityOrSensitivity: value == 'workingLocation' + ? 'public' + : 'default', + allDay: value == 'workingLocation' ? _draft.allDay : false, + end: + value == 'workingLocation' && + _draft.allDay && + start != null + ? DateTime(start.year, start.month, start.day + 1) + : _draft.end, + ), + ); + }, + ), + ]; + if (!googleStatusEventTypes.contains(type)) return rows; + final properties = _draft.googleStatusProperties; + if (type == 'focusTime' || type == 'outOfOffice') { + const modes = [ + 'declineNone', + 'declineOnlyNewConflictingInvitations', + 'declineAllConflictingInvitations', + ]; + final mode = properties['autoDeclineMode']?.toString() ?? modes.first; + rows.add( + DropdownButtonFormField( + key: const Key('android-google-auto-decline'), + initialValue: modes.contains(mode) ? mode : modes.first, + decoration: InputDecoration(labelText: l10n.googleDeclineInvitations), + items: [ + for (final value in modes) + DropdownMenuItem( + value: value, + child: Text(googleAutoDeclineLabel(l10n, value)), + ), + ], + onChanged: !_canEdit + ? null + : (value) { + if (value == null) return; + setState( + () => _draft = _draft.copyWith( + googleStatusProperties: { + ...properties, + 'autoDeclineMode': value, + }, + ), + ); + }, + ), + ); + if (type == 'focusTime') { + rows.add( + DropdownButtonFormField( + key: const Key('android-google-chat-status'), + initialValue: properties['chatStatus'] == 'doNotDisturb' + ? 'doNotDisturb' + : 'available', + decoration: InputDecoration(labelText: l10n.googleChatStatus), + items: [ + DropdownMenuItem( + value: 'available', + child: Text(l10n.googleChatAvailable), + ), + DropdownMenuItem( + value: 'doNotDisturb', + child: Text(l10n.googleChatDoNotDisturb), + ), + ], + onChanged: !_canEdit + ? null + : (value) { + if (value == null) return; + setState( + () => _draft = _draft.copyWith( + googleStatusProperties: { + ...properties, + 'chatStatus': value, + }, + ), + ); + }, + ), + ); + } + rows.add( + TextFormField( + key: ValueKey('android-google-decline-message-$type'), + initialValue: properties['declineMessage']?.toString() ?? '', + enabled: _canEdit, + decoration: InputDecoration(labelText: l10n.googleDeclineMessage), + onChanged: (value) => setState( + () => _draft = _draft.copyWith( + googleStatusProperties: {...properties, 'declineMessage': value}, + ), + ), + ), + ); + return rows; + } + const locations = ['homeOffice', 'officeLocation', 'customLocation']; + final locationType = properties['type']?.toString() ?? locations.first; + rows.add( + DropdownButtonFormField( + key: const Key('android-google-working-location'), + initialValue: locations.contains(locationType) + ? locationType + : locations.first, + decoration: InputDecoration(labelText: l10n.googleWorkingLocation), + items: [ + for (final value in locations) + DropdownMenuItem( + value: value, + child: Text(googleWorkingLocationLabel(l10n, value)), + ), + ], + onChanged: !_canEdit + ? null + : (value) { + if (value == null) return; + final next = {...properties} + ..remove('homeOffice') + ..remove('officeLocation') + ..remove('customLocation'); + next['type'] = value; + next[value] = value == 'homeOffice' + ? {} + : (properties[value] is Map + ? Map.from(properties[value] as Map) + : {}); + setState( + () => _draft = _draft.copyWith(googleStatusProperties: next), + ); + }, + ), + ); + if (locationType != 'homeOffice') { + final detail = properties[locationType] is Map + ? Map.from(properties[locationType] as Map) + : {}; + rows.add( + TextFormField( + key: ValueKey('android-google-location-label-$locationType'), + initialValue: detail['label']?.toString() ?? '', + enabled: _canEdit, + decoration: InputDecoration(labelText: l10n.googleWorkLocationLabel), + onChanged: (value) => setState( + () => _draft = _draft.copyWith( + googleStatusProperties: { + ...properties, + locationType: {...detail, 'label': value}, + }, + ), + ), + ), + ); + } + return rows; + } + + Widget _googleEventLabelField(CalendarSourceEntity source) { + final labels = ref.watch( + googleEventLabelsForCalendarProvider(( + accountId: source.accountId, + calendarId: source.providerCalendarId, + )), + ); + final current = _draft.eventLabelId; + return labels.when( + loading: () => ListTile( + title: Text(context.l10n.eventLabel), + subtitle: Text(current ?? context.l10n.noneValue), + ), + error: (_, _) => ListTile( + title: Text(context.l10n.eventLabel), + subtitle: Text(current ?? context.l10n.noneValue), + trailing: TextButton( + onPressed: () => ref.invalidate( + googleEventLabelsForCalendarProvider(( + accountId: source.accountId, + calendarId: source.providerCalendarId, + )), + ), + child: Text(context.l10n.retry), + ), + ), + data: (available) { + final byId = {for (final label in available) label.id: label}; + final ids = [ + '', + ...byId.keys, + if (current != null && !byId.containsKey(current)) current, + ]; + return DropdownButtonFormField( + key: ValueKey('android-event-label-${source.id}'), + isExpanded: true, + initialValue: current ?? '', + decoration: InputDecoration(labelText: context.l10n.eventLabel), + items: [ + for (final id in ids) + DropdownMenuItem( + key: ValueKey('android-event-label-option-$id'), + value: id, + child: Text.rich( + TextSpan( + children: [ + if (byId[id] case final label?) + TextSpan( + text: '● ', + style: TextStyle( + color: Color( + 0xff000000 | + int.parse( + label.backgroundColor.substring(1), + radix: 16, + ), + ), + ), + ), + TextSpan( + text: id.isEmpty + ? context.l10n.noneValue + : byId[id]?.name ?? + context.l10n.unknownEventLabel(id), + ), + ], + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + onChanged: !_canEdit + ? null + : (id) => setState( + () => _draft = id == null || id.isEmpty + ? _draft.copyWith(clearEventLabelId: true) + : _draft.copyWith(eventLabelId: id), + ), + ); + }, + ); + } + + Widget _microsoftCategoryField(CalendarSourceEntity source) { + final catalog = ref.watch( + microsoftMasterCategoriesProvider(source.accountId), + ); + return catalog.when( + loading: () => const LinearProgressIndicator(), + error: (_, _) => TextButton( + onPressed: () async { + try { + await ref + .read(microsoftCategoryAuthorizationProvider) + ?.authorizeCategoryAccess(source.accountId); + if (mounted) { + ref.invalidate( + microsoftMasterCategoriesProvider(source.accountId), + ); + } + } on Object { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(context.l10n.outlookCategoriesUnavailable), + ), + ); + } + } + }, + child: Text(context.l10n.loadOutlookCategories), + ), + data: (categories) => Wrap( + spacing: 8, + runSpacing: 4, + children: [ + for (final category in categories) + FilterChip( + label: Text(category.displayName), + avatar: microsoftCategorySwatchArgb(category.color) == null + ? null + : CircleAvatar( + backgroundColor: Color( + microsoftCategorySwatchArgb(category.color)!, + ), + radius: 7, + ), + selected: _categories.text + .split(',') + .map((value) => value.trim()) + .contains(category.displayName), + onSelected: !_canEdit + ? null + : (selected) { + final values = _categories.text + .split(',') + .map((value) => value.trim()) + .where((value) => value.isNotEmpty) + .toList(); + if (selected) { + if (!values.contains(category.displayName)) { + values.add(category.displayName); + } + } else { + values.remove(category.displayName); + } + setState(() => _categories.text = values.join(', ')); + }, + ), + ], + ), + ); + } + Future _save() async { final start = _draft.start; if (_title.text.trim().isEmpty || start == null) return; - final attendeeEdit = mergeAndroidEventAttendees( - widget.draft.attendees, - _guests.text, + final attendeeEdit = applyAndroidEventAttendeeRoles( + mergeAndroidEventAttendees(widget.draft.attendees, _guests.text), + _guestOptionalOverrides, ); final categories = _categories.text .split(',') @@ -2333,7 +3305,9 @@ class _AndroidEventEditorState extends ConsumerState { var draft = _draft.copyWith( title: _title.text.trim(), location: _location.text.trim(), - description: _description.text.trim(), + description: _description.text == (_initialDescription ?? '') + ? null + : _description.text.trim(), attendees: attendeeEdit.attendees, attendeesChanged: attendeeEdit.changed, categories: categories, @@ -2344,11 +3318,28 @@ class _AndroidEventEditorState extends ConsumerState { draft = draft.copyWith( reminders: _eventReminders(_provider, value), remindersChanged: true, - clearReminders: value == 0, + clearReminders: false, + ); + } + if (_provider == BusyProvider.google && _editedGoogleReminders != null) { + draft = draft.copyWith( + reminders: _editedGoogleReminders, + remindersChanged: true, + ); + } + if (_provider == BusyProvider.nextcloud && + _editedNextcloudReminders != null) { + draft = draft.copyWith( + reminders: { + 'davEditableRows': [ + for (final row in _editedNextcloudReminders!) row.toJson(), + ], + }, + remindersChanged: true, ); } if (_recurrenceChanged) { - final rule = _simpleRecurrenceRule(_frequency, start); + final rule = _recurrenceRule; draft = draft.copyWith( recurrence: rule.repeats ? EventRecurrenceCodec.encode( @@ -2357,6 +3348,7 @@ class _AndroidEventEditorState extends ConsumerState { baseDate: start, allDay: draft.allDay, timeZone: draft.startTimeZone, + original: widget.draft.recurrence, ) : null, recurrenceChanged: true, @@ -2372,7 +3364,9 @@ class _AndroidEventEditorState extends ConsumerState { } setState(() => _saving = true); try { - if ((_reminderMinutes ?? 0) > 0) { + if ((_reminderMinutes ?? -2) >= 0 || + androidGooglePopupReminderRows(_editedGoogleReminders).isNotEmpty || + (_editedNextcloudReminders?.isNotEmpty ?? false)) { await ref .read(androidNotificationServiceProvider) .requestNotificationPermission(); @@ -2429,7 +3423,18 @@ class _AndroidEventEditorState extends ConsumerState { _guests.text, ).attendees.any((attendee) => !attendee.self && !attendee.organizer); if (!hasGuests) return false; - if (source.provider == BusyProvider.google) return true; + if (source.provider == BusyProvider.google) { + return true; + } + if (source.provider == BusyProvider.microsoft) { + final account = ref + .watch(accountsStreamProvider) + .valueOrNull + ?.where((candidate) => candidate.id == source.accountId) + .firstOrNull; + return microsoftAvailabilityAccountType(account?.tenantId) == + MicrosoftAvailabilityAccountType.workSchool; + } return source.provider == BusyProvider.nextcloud && source.davCollectionId != null && source.davEffectivePermissions['canQueryFreeBusy'] == true; @@ -2447,6 +3452,7 @@ class _AndroidEventEditorState extends ConsumerState { accountId: source.accountId, provider: source.provider, collectionId: source.davCollectionId, + calendarTimeZone: source.timeZone, draft: _draft.copyWith(attendees: attendees), ); } @@ -2618,6 +3624,12 @@ class _AndroidEventEditorState extends ConsumerState { final frequency = RecurrenceFrequency.values .where((value) => value.name == map['frequency']) .firstOrNull; + final recoveredRule = map['recurrenceRule'] is String + ? RecurrenceRule.fromJson( + map['recurrenceRule'] as String, + baseDate: start, + ) + : null; final recurringScope = RecurringEventMutationScope.values .where((value) => value.name == map['recurringMutationScope']) .firstOrNull; @@ -2651,10 +3663,33 @@ class _AndroidEventEditorState extends ConsumerState { _location.text = map['location']?.toString() ?? _location.text; _description.text = map['description']?.toString() ?? _description.text; _guests.text = map['guests']?.toString() ?? _guests.text; + _guestOptionalOverrides.clear(); + if (map['guestOptionalOverrides'] case final Map overrides) { + for (final entry in overrides.entries) { + if (entry.key is String && entry.value is bool) { + _guestOptionalOverrides[entry.key as String] = + entry.value as bool; + } + } + } _categories.text = map['categories']?.toString() ?? _categories.text; _reminderMinutes = map['reminderMinutes'] as int?; - if (frequency != null && frequency != _frequency) { - _frequency = frequency; + _editedGoogleReminders = map['googleReminders'] is Map + ? (map['googleReminders'] as Map).cast() + : null; + _editedNextcloudReminders = map['nextcloudReminders'] is List + ? [ + for (final value in map['nextcloudReminders'] as List) + AndroidNextcloudReminderRow.fromJson(value), + ] + : null; + if (recoveredRule != null && recoveredRule != _recurrenceRule) { + _recurrenceRule = recoveredRule; + _recurrenceChanged = true; + } else if (recoveredRule == null && + frequency != null && + frequency != _recurrenceRule.frequency) { + _recurrenceRule = androidRuleForFrequency(frequency, start); _recurrenceChanged = true; } _draft = _draft.copyWith( @@ -2673,6 +3708,44 @@ class _AndroidEventEditorState extends ConsumerState { clearShowAs: map['showAs'] == null, visibilityOrSensitivity: map['visibility']?.toString(), clearVisibilityOrSensitivity: map['visibility'] == null, + createConference: + map['createConference'] == true && + (recoveredSource.provider == BusyProvider.google + ? recoveredSource.allowedConferenceSolutions.contains( + 'hangoutsMeet', + ) + : recoveredSource.provider == BusyProvider.microsoft && + recoveredSource.allowedConferenceSolutions.contains( + 'teamsForBusiness', + )), + eventLabelId: + map['eventLabelChanged'] == true && + recoveredSource.provider == BusyProvider.google && + map['eventLabelSourceId'] == recoveredSource.id + ? map['eventLabelId']?.toString() + : null, + clearEventLabelId: + map['eventLabelChanged'] == true && + recoveredSource.provider == BusyProvider.google && + map['eventLabelSourceId'] == recoveredSource.id && + map['eventLabelId'] == null, + eventLabelChanged: + map['eventLabelChanged'] == true && + recoveredSource.provider == BusyProvider.google && + map['eventLabelSourceId'] == recoveredSource.id, + eventType: + recoveredSource.provider == BusyProvider.google && + recoveredSource.primaryCalendar && + (widget.draft.eventId == null || + map['eventType'] == widget.draft.eventType) + ? map['eventType']?.toString() + : null, + googleStatusProperties: + recoveredSource.provider == BusyProvider.google && + recoveredSource.primaryCalendar && + map['googleStatusProperties'] is Map + ? Map.from(map['googleStatusProperties'] as Map) + : null, ); }); } on Object { @@ -2705,12 +3778,25 @@ class _AndroidEventEditorState extends ConsumerState { 'location': _location.text, 'description': _description.text, 'guests': _guests.text, + 'guestOptionalOverrides': _guestOptionalOverrides, 'categories': _categories.text, 'allDay': _draft.allDay, 'start': _draft.start?.toIso8601String(), 'end': _draft.end?.toIso8601String(), 'reminderMinutes': _reminderMinutes, - 'frequency': _frequency.name, + 'googleReminders': _editedGoogleReminders, + 'nextcloudReminders': _editedNextcloudReminders == null + ? null + : [for (final row in _editedNextcloudReminders!) row.toJson()], + 'createConference': _draft.createConference, + 'eventLabelId': _draft.eventLabelId, + 'eventLabelChanged': _draft.eventLabelChanged, + 'eventLabelSourceId': _draft.sourceId, + 'eventType': _draft.eventType, + 'googleStatusProperties': _draft.googleStatusProperties, + 'frequency': _recurrenceRule.frequency.name, + if (_recurrenceChanged && _recurrenceRule.isSupported) + 'recurrenceRule': _recurrenceRule.toJsonString(), }); await _enqueueRecovery(() async { if (_recoveryWritesBlocked) return; @@ -2747,6 +3833,26 @@ final class AndroidEventAttendeeEdit { final bool changed; } +AndroidEventAttendeeEdit applyAndroidEventAttendeeRoles( + AndroidEventAttendeeEdit edit, + Map optionalByEmail, +) { + if (optionalByEmail.isEmpty) return edit; + final attendees = [ + for (final attendee in edit.attendees) + if (!attendee.self && + !attendee.organizer && + optionalByEmail.containsKey(attendee.email.toLowerCase())) + attendee.withOptional(optionalByEmail[attendee.email.toLowerCase()]!) + else + attendee, + ]; + return AndroidEventAttendeeEdit( + attendees: attendees, + changed: edit.changed || !listEquals(attendees, edit.attendees), + ); +} + /// 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. @@ -2791,31 +3897,6 @@ String _scopeLabel(BuildContext context, RecurringEventMutationScope scope) => 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, @@ -3004,7 +4085,14 @@ int _firstWeekday(BuildContext context) { } Object? _eventReminders(BusyProvider provider, int minutes) { - if (minutes <= 0) return null; + if (minutes == -1 && provider == BusyProvider.google) { + return const {'useDefault': true}; + } + if (minutes < 0) { + return provider == BusyProvider.microsoft + ? const {'isReminderOn': false} + : const {'useDefault': false, 'overrides': []}; + } return provider == BusyProvider.microsoft ? {'isReminderOn': true, 'reminderMinutesBeforeStart': minutes} : { @@ -3015,6 +4103,269 @@ Object? _eventReminders(BusyProvider provider, int minutes) { }; } +final class AndroidNextcloudReminderRow { + const AndroidNextcloudReminderRow({ + required this.minutes, + this.originalIndex, + }); + + final int minutes; + final int? originalIndex; + + AndroidNextcloudReminderRow copyWith({required int minutes}) => + AndroidNextcloudReminderRow( + minutes: minutes, + originalIndex: originalIndex, + ); + + Map toJson() => { + if (originalIndex != null) 'originalIndex': originalIndex, + 'minutes': minutes, + }; + + static AndroidNextcloudReminderRow fromJson(Object? value) { + if (value is! Map || + value['minutes'] is! int || + (value['minutes'] as int) < 0 || + (value['originalIndex'] != null && value['originalIndex'] is! int)) { + throw const FormatException('Invalid recovered DAV reminder.'); + } + return AndroidNextcloudReminderRow( + minutes: value['minutes'] as int, + originalIndex: value['originalIndex'] as int?, + ); + } +} + +List? androidNextcloudEditableReminderRows( + Object? value, +) { + if (value == null) return const []; + if (value is! Map) return null; + if (value['davEditableRows'] is List) { + try { + return [ + for (final row in value['davEditableRows'] as List) + AndroidNextcloudReminderRow.fromJson(row), + ]; + } on FormatException { + return null; + } + } + if (value['alarms'] case final List alarms) { + final rows = []; + for (var index = 0; index < alarms.length; index++) { + final alarm = alarms[index]; + if (alarm is! Map || alarm['properties'] is! List) return null; + final properties = alarm['properties'] as List; + String? property(String name) { + for (final item in properties) { + if (item is Map && item['name'] == name) { + return item['value']?.toString(); + } + } + return null; + } + + if (property('ACTION')?.toUpperCase() != 'DISPLAY') continue; + final triggerProperty = properties + .whereType() + .where((item) => item['name']?.toString().toUpperCase() == 'TRIGGER') + .firstOrNull; + final triggerParameters = triggerProperty?['parameters']; + if (triggerParameters is List && + triggerParameters.whereType().any((parameter) { + if (parameter['name']?.toString().toUpperCase() != 'RELATED') { + return false; + } + final values = parameter['values']; + return values is! List || + values.length != 1 || + values.single.toString().toUpperCase() != 'START'; + })) { + continue; + } + final trigger = property('TRIGGER')?.toUpperCase(); + final match = trigger == null + ? null + : RegExp(r'^-PT([0-9]+)M$').firstMatch(trigger); + if (match != null) { + rows.add( + AndroidNextcloudReminderRow( + minutes: int.parse(match.group(1)!), + originalIndex: index, + ), + ); + } + } + return rows; + } + if (value['minutes'] case final List minutes) { + if (minutes.any((minute) => minute is! int || minute < 0)) return null; + return [ + for (final minute in minutes) + AndroidNextcloudReminderRow(minutes: minute as int), + ]; + } + return null; +} + +bool androidNextcloudHasUnsupportedAlarms(Object? value) { + if (value is! Map || value['alarms'] is! List) return false; + final rows = androidNextcloudEditableReminderRows(value); + return rows != null && rows.length < (value['alarms'] as List).length; +} + +/// A negative selection is explicit default (-1) or no reminder (-2). +/// Null means a provider-specific representation that this single-choice +/// editor must preserve until the user actively replaces it. +int? androidEventReminderSelection(BusyProvider provider, Object? value) { + if (value == null) return provider == BusyProvider.google ? -1 : -2; + if (value is! Map) return null; + if (provider == BusyProvider.microsoft) { + if (value['isReminderOn'] == false) return -2; + return value['reminderMinutesBeforeStart'] is int + ? value['reminderMinutesBeforeStart'] as int + : null; + } + if (value['useDefault'] == true) return -1; + final overrides = value['overrides']; + if (overrides is List) { + if (overrides.isEmpty) return -2; + if (overrides.length == 1 && overrides.single is Map) { + final entry = overrides.single as Map; + if (entry['method'] == 'popup' && entry['minutes'] is int) { + return entry['minutes'] as int; + } + } + } + return null; +} + +List? androidEventReminderMinutes(BusyProvider provider, Object? value) { + if (value is! Map) return null; + if (provider == BusyProvider.microsoft) { + if (value['isReminderOn'] == false) return const []; + final minutes = value['reminderMinutesBeforeStart']; + return minutes is int && minutes >= 0 ? [minutes] : null; + } + if (value['useDefault'] == true) return null; + final overrides = value['overrides']; + if (overrides is List) { + final result = []; + for (final entry in overrides) { + if (entry is! Map || + entry['method'] != 'popup' || + entry['minutes'] is! int || + (entry['minutes'] as int) < 0) { + return null; + } + result.add(entry['minutes'] as int); + } + return result; + } + final minutes = value['minutes']; + return minutes is List && + minutes.every((element) => element is int && element >= 0) + ? minutes.cast() + : null; +} + +int? androidGoogleReminderMode(Object? value) { + if (value == null) return -1; + if (value is! Map) return null; + if (value['useDefault'] == true) return -1; + if (value['useDefault'] != false || value['overrides'] is! List) { + return null; + } + return (value['overrides'] as List).isEmpty ? -2 : -3; +} + +List<({int index, int minutes})> androidGooglePopupReminderRows(Object? value) { + if (value is! Map || value['overrides'] is! List) return const []; + final overrides = value['overrides'] as List; + return [ + for (var index = 0; index < overrides.length; index++) + if (overrides[index] case final Map entry + when entry['method'] == 'popup' && + entry['minutes'] is int && + (entry['minutes'] as int) >= 0) + (index: index, minutes: entry['minutes'] as int), + ]; +} + +int androidGoogleReminderOverrideCount(Object? value) => + value is Map && value['overrides'] is List + ? (value['overrides'] as List).length + : 0; + +bool androidGoogleHasUnsupportedReminder(Object? value) { + if (value is! Map || value['overrides'] is! List) return false; + return androidGooglePopupReminderRows(value).length != + (value['overrides'] as List).length; +} + +Map androidSetGoogleReminderMode(Object? value, int mode) { + if (mode == -1) return const {'useDefault': true}; + if (mode == -2) return const {'useDefault': false, 'overrides': []}; + if (mode != -3) throw ArgumentError.value(mode, 'mode'); + final original = value is Map ? value.cast() : const {}; + final existing = original['overrides']; + return { + ...original, + 'useDefault': false, + 'overrides': existing is List && existing.isNotEmpty + ? [...existing] + : [ + {'method': 'popup', 'minutes': 10}, + ], + }; +} + +Map androidEditGooglePopupReminder( + Object? value, { + required int? overrideIndex, + required int minutes, +}) { + if (minutes < 0) throw ArgumentError.value(minutes, 'minutes'); + final original = value is Map ? value.cast() : const {}; + final overrides = [...?original['overrides'] as List?]; + if (overrideIndex == null) { + if (overrides.length >= 5) { + throw StateError('Google allows at most five event reminders.'); + } + overrides.add({'method': 'popup', 'minutes': minutes}); + } else { + if (overrideIndex < 0 || + overrideIndex >= overrides.length || + overrides[overrideIndex] is! Map || + (overrides[overrideIndex] as Map)['method'] != 'popup') { + throw RangeError.index(overrideIndex, overrides); + } + overrides[overrideIndex] = { + ...(overrides[overrideIndex] as Map).cast(), + 'minutes': minutes, + }; + } + return {...original, 'useDefault': false, 'overrides': overrides}; +} + +Map androidRemoveGooglePopupReminder( + Object? value, { + required int overrideIndex, +}) { + final original = value is Map ? value.cast() : const {}; + final overrides = [...?original['overrides'] as List?]; + if (overrideIndex < 0 || + overrideIndex >= overrides.length || + overrides[overrideIndex] is! Map || + (overrides[overrideIndex] as Map)['method'] != 'popup') { + throw RangeError.index(overrideIndex, overrides); + } + overrides.removeAt(overrideIndex); + return {...original, 'useDefault': false, 'overrides': overrides}; +} + List _eventShowAsValues(BusyProvider provider) => provider == BusyProvider.microsoft ? const ['free', 'tentative', 'busy', 'oof', 'workingElsewhere'] diff --git a/lib/src/android/presentation/android_settings_screen.dart b/lib/src/android/presentation/android_settings_screen.dart index f836f1c6..0e0430d4 100644 --- a/lib/src/android/presentation/android_settings_screen.dart +++ b/lib/src/android/presentation/android_settings_screen.dart @@ -19,6 +19,9 @@ 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/calendar/data/cloud_calendar_sharing_service.dart'; +import '../../features/calendar/data/microsoft_shared_calendar_service.dart'; +import 'android_cloud_calendar_sharing_content.dart'; import '../../features/sync/sync_auth_error.dart'; import '../../features/task_lists/data/task_lists_repository.dart'; import '../../features/tasks/domain/task_capabilities.dart'; @@ -137,6 +140,43 @@ class _AndroidSettingsScreenState extends ConsumerState { onTap: () => unawaited(_createCollection(accounts, calendar: true)), ), + for (final account in accounts) + if (account.provider == BusyProvider.microsoft && + account.isSignedIn) + ListTile( + leading: const Icon(Icons.people_outline), + title: Text(context.l10n.openSharedCalendar), + subtitle: Text( + account.displayName ?? account.email ?? account.id, + ), + onTap: () => unawaited(_openSharedCalendar(account.id)), + ), + for (final account in accounts) + if (account.isSignedIn && + sources.any( + (source) => + source.accountId == account.id && + CloudCalendarSharingService.canManageSource(source), + )) + ListTile( + key: Key('android-manage-sharing-${account.id}'), + leading: const Icon(Icons.people_outline), + title: Text(context.l10n.manageCalendarSharing), + subtitle: Text(account.displayLabel), + onTap: () => unawaited( + _manageCalendarSharing( + sources + .where( + (source) => + source.accountId == account.id && + CloudCalendarSharingService.canManageSource( + source, + ), + ) + .toList(), + ), + ), + ), if (accounts.any( (account) => account.isSignedIn && @@ -828,6 +868,69 @@ class _AndroidSettingsScreenState extends ConsumerState { } } + Future _openSharedCalendar(String accountId) async { + final controller = TextEditingController(); + final l10n = context.l10n; + final owner = await showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: Text(l10n.openSharedCalendar), + content: TextField( + controller: controller, + keyboardType: TextInputType.emailAddress, + autofocus: true, + decoration: InputDecoration(labelText: l10n.calendarOwnerEmail), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext), + child: Text(l10n.cancel), + ), + TextButton( + onPressed: () => Navigator.pop(dialogContext, controller.text), + child: Text(l10n.openSharedCalendar), + ), + ], + ), + ); + controller.dispose(); + if (!mounted || owner == null || owner.trim().isEmpty) return; + try { + final result = await ref + .read(microsoftSharedCalendarServiceProvider) + .openPrimaryCalendar(accountId: accountId, owner: owner); + if (result.outcome == + MicrosoftSharedCalendarOpenOutcome.rangeUnavailable) { + _message(l10n.scheduleRangeIncomplete); + } + } on Object catch (error) { + _message(l10n.calendarUpdateFailed('$error')); + } + } + + Future _manageCalendarSharing( + List sources, + ) async { + await showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: Text(dialogContext.l10n.manageCalendarSharing), + content: SizedBox( + width: double.maxFinite, + child: SingleChildScrollView( + child: CloudCalendarSharingContent(sources: sources), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext), + child: Text(dialogContext.l10n.close), + ), + ], + ), + ); + } + Future _editCalendarSource(CalendarSourceEntity source) async { final l10n = context.l10n; final action = await showModalBottomSheet( @@ -879,9 +982,22 @@ class _AndroidSettingsScreenState extends ConsumerState { 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, + title: Text( + source.capabilities.removalMode == + CalendarRemovalMode.removeFromList + ? context.l10n.removeFromMyCalendars + : context.l10n.delete, + ), + textColor: + source.capabilities.removalMode == + CalendarRemovalMode.delete + ? Theme.of(context).colorScheme.error + : null, + iconColor: + source.capabilities.removalMode == + CalendarRemovalMode.delete + ? Theme.of(context).colorScheme.error + : null, onTap: () => Navigator.pop(context, 'delete'), ), ], @@ -945,16 +1061,22 @@ class _AndroidSettingsScreenState extends ConsumerState { final remove = source.capabilities.removalMode == CalendarRemovalMode.removeFromList; + final openedMicrosoftOwner = + remove && source.provider == BusyProvider.microsoft; final confirmed = await _confirm( remove ? l10n.removeFromMyCalendars : l10n.delete, remove - ? l10n.removeCalendarConfirmation(source.summary) + ? openedMicrosoftOwner + ? l10n.removeOpenedSharedCalendarConfirmation( + source.summary, + ) + : 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); + if (!openedMicrosoftOwner) _requestQueuedCalendarSync(source); } } on Object catch (error) { _message( diff --git a/lib/src/android/presentation/android_tasks_screen.dart b/lib/src/android/presentation/android_tasks_screen.dart index 7ee9a7a3..60f7c077 100644 --- a/lib/src/android/presentation/android_tasks_screen.dart +++ b/lib/src/android/presentation/android_tasks_screen.dart @@ -14,9 +14,16 @@ 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/domain/task_source_links.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 '../../features/schedule/presentation/schedule_event_details_format.dart'; +import '../../features/schedule/presentation/attachment_download.dart'; +import '../../features/schedule/presentation/attachment_upload_coordinator.dart'; +import '../../microsoft_todo/api/microsoft_todo_api_client.dart'; +import '../../microsoft_todo/api/microsoft_todo_api_models.dart'; +import '../../microsoft_calendar/microsoft_calendar_models.dart'; import '../../l10n/l10n.dart'; import '../../providers/busy_provider.dart'; import '../../schedule/schedule_filters.dart'; @@ -558,6 +565,9 @@ class AndroidTaskEditor extends ConsumerStatefulWidget { } class _AndroidTaskEditorState extends ConsumerState { + Future>? _linkedResourcesFuture; + Future>? _attachmentsFuture; + bool _attachmentBusy = false; late TaskDetailsDraft _draft = widget.task == null ? TaskDetailsDraft.forCreation( taskListId: widget.creationList!.id, @@ -696,6 +706,249 @@ class _AndroidTaskEditorState extends ConsumerState { subtitle: Text(widget.accountLabel ?? widget.accountId), ), ), + if (widget.provider == BusyProvider.google && widget.task != null) + for (final link in widget.task!.googleSourceLinks) + ListTile( + leading: const Icon(Icons.open_in_new), + title: Text( + link.label?.isNotEmpty == true + ? link.label! + : context.l10n.openInProvider, + ), + subtitle: Text(link.url), + onTap: () async { + if (!await openScheduleWebLink(link.url) && + context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(context.l10n.eventLinkOpenFailed), + ), + ); + } + }, + ), + if (widget.provider == BusyProvider.microsoft && + widget.task != null) + Column( + children: [ + TextButton( + onPressed: () => setState(() { + final key = ( + accountId: widget.accountId, + taskListId: widget.task!.taskListId, + taskId: widget.task!.id, + ); + ref.invalidate(microsoftTaskLinkedResourcesProvider(key)); + _linkedResourcesFuture = ref.read( + microsoftTaskLinkedResourcesProvider(key).future, + ); + }), + child: Text(context.l10n.linkedResources), + ), + if (_linkedResourcesFuture case final future?) + FutureBuilder>( + future: future, + builder: (context, snapshot) { + if (snapshot.hasError) { + return Text(context.l10n.operationFailed); + } + if (!snapshot.hasData) { + return const LinearProgressIndicator(); + } + if (snapshot.data!.isEmpty) { + return Text(context.l10n.noLinkedResources); + } + return Column( + children: [ + for (final link in snapshot.data!) + ListTile( + leading: const Icon(Icons.open_in_new), + title: Text( + link.label?.isNotEmpty == true + ? link.label! + : context.l10n.openInProvider, + ), + subtitle: Text(link.url), + onTap: () async { + if (!await openScheduleWebLink(link.url) && + context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + context.l10n.eventLinkOpenFailed, + ), + ), + ); + } + }, + ), + ], + ); + }, + ), + ], + ), + if (widget.provider == BusyProvider.microsoft && + widget.task != null) + Column( + children: [ + TextButton( + key: const Key('task-attachments-load'), + onPressed: () => setState(() { + final key = ( + accountId: widget.accountId, + taskListId: widget.task!.taskListId, + taskId: widget.task!.id, + ); + ref.invalidate(microsoftTaskAttachmentsProvider(key)); + _attachmentsFuture = ref.read( + microsoftTaskAttachmentsProvider(key).future, + ); + }), + child: Text(context.l10n.attachments), + ), + if (canWrite && + !widget.task!.pendingDelete && + !widget.task!.id.startsWith('local-task-')) + TextButton.icon( + onPressed: + _attachmentBusy || + !ref + .watch(attachmentUploadCoordinatorProvider) + .canSubmit( + AttachmentUploadCoordinator.taskKey( + widget.accountId, + widget.task!.taskListId, + widget.task!.id, + ), + ) + ? null + : _uploadTaskAttachment, + icon: const Icon(Icons.add), + label: Text(context.l10n.attachments), + ), + if (ref + .watch(attachmentUploadCoordinatorProvider) + .retryAfter( + AttachmentUploadCoordinator.taskKey( + widget.accountId, + widget.task!.taskListId, + widget.task!.id, + ), + ) != + null) + Text(context.l10n.attachmentUploadRateLimited), + if (ref + .watch(attachmentUploadCoordinatorProvider) + .needsReconciliation( + AttachmentUploadCoordinator.taskKey( + widget.accountId, + widget.task!.taskListId, + widget.task!.id, + ), + )) ...[ + Text( + ref + .watch(attachmentUploadCoordinatorProvider) + .confirmedId( + AttachmentUploadCoordinator.taskKey( + widget.accountId, + widget.task!.taskListId, + widget.task!.id, + ), + ) == + null + ? context.l10n.attachmentUploadUnresolved + : context.l10n.completed, + ), + TextButton( + onPressed: _attachmentBusy ? null : _reconcileTaskUpload, + child: Text(context.l10n.refresh), + ), + if (ref + .watch(attachmentUploadCoordinatorProvider) + .hasResumableSession( + AttachmentUploadCoordinator.taskKey( + widget.accountId, + widget.task!.taskListId, + widget.task!.id, + ), + )) + TextButton( + onPressed: _attachmentBusy ? null : _cancelTaskUpload, + child: Text(context.l10n.cancel), + ), + if (ref + .watch(attachmentUploadCoordinatorProvider) + .canResolveManually( + AttachmentUploadCoordinator.taskKey( + widget.accountId, + widget.task!.taskListId, + widget.task!.id, + ), + )) ...[ + TextButton( + onPressed: _attachmentBusy + ? null + : () => _resolveTaskUpload(exists: true), + child: Text(context.l10n.completed), + ), + TextButton( + onPressed: _attachmentBusy + ? null + : () => _resolveTaskUpload(exists: false), + child: Text(context.l10n.retry), + ), + ], + ], + if (_attachmentsFuture case final future?) + FutureBuilder>( + future: future, + builder: (context, snapshot) { + if (snapshot.hasError) { + return Text(context.l10n.attachmentsNotLoaded); + } + if (!snapshot.hasData) { + return const LinearProgressIndicator(); + } + if (snapshot.data!.isEmpty) { + return Text(context.l10n.noneValue); + } + return Column( + children: [ + for (final attachment in snapshot.data!) + ListTile( + leading: const Icon(Icons.attach_file), + title: Text(attachment.name), + subtitle: attachment.size == null + ? null + : Text('${attachment.size} B'), + trailing: + canWrite && + !widget.task!.pendingDelete && + !widget.task!.id.startsWith( + 'local-task-', + ) + ? IconButton( + tooltip: context.l10n.delete, + onPressed: _attachmentBusy + ? null + : () => _deleteTaskAttachment( + attachment, + ), + icon: const Icon(Icons.delete_outline), + ) + : null, + onTap: attachment.isFile + ? () => _downloadTaskAttachment(attachment) + : null, + ), + ], + ); + }, + ), + ], + ), const SizedBox(height: 12), TextField( controller: _title, @@ -708,7 +961,9 @@ class _AndroidTaskEditorState extends ConsumerState { const SizedBox(height: 12), TextField( controller: _notes, - enabled: canWrite, + enabled: + canWrite && + !(widget.task?.googleAssignment.isFromDocument ?? false), minLines: 3, maxLines: 8, decoration: InputDecoration(labelText: context.l10n.notes), @@ -948,6 +1203,8 @@ class _AndroidTaskEditorState extends ConsumerState { ), ), ), + if (widget.provider == BusyProvider.microsoft) + _microsoftCategories(canWrite), ], if (capabilities.supportsLocation) ...[ const SizedBox(height: 12), @@ -1214,7 +1471,11 @@ class _AndroidTaskEditorState extends ConsumerState { : () => _openHierarchyTask(subtask.task!), ), OutlinedButton.icon( - onPressed: capabilities.canCreateTasks ? _createSubtask : null, + onPressed: + capabilities.canCreateTasks && + !(widget.task?.googleAssignment.isAssigned ?? false) + ? _createSubtask + : null, icon: const Icon(Icons.add_task), label: Text(context.l10n.createSubtask), ), @@ -1270,6 +1531,7 @@ class _AndroidTaskEditorState extends ConsumerState { } Future _createSubtask() async { + if (widget.task?.googleAssignment.isAssigned ?? false) return; final controller = TextEditingController(); final title = await showDialog( context: context, @@ -1593,6 +1855,73 @@ class _AndroidTaskEditorState extends ConsumerState { return alarm.triggerRaw; } + Widget _microsoftCategories(bool canWrite) { + final catalog = ref.watch( + microsoftMasterCategoriesProvider(widget.accountId), + ); + return catalog.when( + loading: () => const LinearProgressIndicator(), + error: (_, _) => TextButton( + onPressed: () async { + try { + await ref + .read(microsoftCategoryAuthorizationProvider) + ?.authorizeCategoryAccess(widget.accountId); + if (mounted) { + ref.invalidate( + microsoftMasterCategoriesProvider(widget.accountId), + ); + } + } on Object { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(context.l10n.outlookCategoriesUnavailable), + ), + ); + } + } + }, + child: Text(context.l10n.loadOutlookCategories), + ), + data: (categories) => Wrap( + spacing: 8, + runSpacing: 4, + children: [ + for (final category in categories) + FilterChip( + label: Text(category.displayName), + avatar: microsoftCategorySwatchArgb(category.color) == null + ? null + : CircleAvatar( + backgroundColor: Color( + microsoftCategorySwatchArgb(category.color)!, + ), + radius: 7, + ), + selected: _draft.categories.contains(category.displayName), + onSelected: !canWrite + ? null + : (selected) { + final values = [..._draft.categories]; + if (selected) { + if (!values.contains(category.displayName)) { + values.add(category.displayName); + } + } else { + values.remove(category.displayName); + } + setState(() { + _categories.text = values.join(', '); + _draft = _draft.copyWith(categories: values); + }); + }, + ), + ], + ), + ); + } + Future _save() async { setState(_syncDraftFromControllers); if (!_draftIsValid) return; @@ -1794,7 +2123,11 @@ class _AndroidTaskEditorState extends ConsumerState { context: context, builder: (context) => AlertDialog( title: Text(context.l10n.deleteTask), - content: Text(context.l10n.deleteTaskConfirmation(widget.task!.title)), + content: Text( + widget.task!.googleAssignment.isAssigned + ? '${context.l10n.deleteTaskConfirmation(widget.task!.title)}\n\n${context.l10n.deleteAssignedTaskWarning}' + : context.l10n.deleteTaskConfirmation(widget.task!.title), + ), actions: [ TextButton( onPressed: () => Navigator.pop(context, false), @@ -1820,7 +2153,12 @@ class _AndroidTaskEditorState extends ConsumerState { try { await ref .read(tasksRepositoryForAccountProvider(widget.accountId)) - .deleteTask(widget.task!.taskListId, widget.task!.id); + .deleteTask( + widget.task!.taskListId, + widget.task!.id, + confirmedAssignedSourceDeletion: + widget.task!.googleAssignment.isAssigned, + ); mutationPersisted = true; widget.onMutationCommitted?.call(result); if (mounted) { @@ -1994,6 +2332,232 @@ class _AndroidTaskEditorState extends ConsumerState { } } + Future _uploadTaskAttachment() async { + final task = widget.task; + if (task == null || + task.pendingDelete || + task.id.startsWith('local-task-')) { + return; + } + final document = await BusyMaxAndroidPlatform.instance.openDocument( + mimeTypes: const ['*/*'], + maximumBytes: 25 * 1024 * 1024, + ); + if (document == null || !mounted) return; + final name = safeAttachmentFileName(document.name ?? ''); + if (name == null) { + _attachmentError(const FormatException('Invalid attachment name.')); + return; + } + setState(() => _attachmentBusy = true); + try { + final client = ref.read( + microsoftTodoApiClientForAccountProvider(widget.accountId), + ); + if (client is! MicrosoftTodoAttachmentsApiClient) { + throw StateError('Microsoft task attachments are unavailable.'); + } + await ref + .read(attachmentUploadCoordinatorProvider) + .uploadTask( + client: client as MicrosoftTodoAttachmentsApiClient, + accountId: widget.accountId, + taskListId: task.taskListId, + taskId: task.id, + name: name, + contentType: document.mimeType ?? 'application/octet-stream', + bytes: document.bytes, + ); + _reloadTaskAttachments(); + } on Object catch (error) { + _reloadTaskAttachments(); + _attachmentError(error); + } finally { + if (mounted) setState(() => _attachmentBusy = false); + } + } + + Future _reconcileTaskUpload() async { + final task = widget.task; + if (task == null) return; + setState(() => _attachmentBusy = true); + try { + final client = ref.read( + microsoftTodoApiClientForAccountProvider(widget.accountId), + ); + if (client is! MicrosoftTodoAttachmentsApiClient) return; + await ref + .read(attachmentUploadCoordinatorProvider) + .reconcileTask( + client: client as MicrosoftTodoAttachmentsApiClient, + accountId: widget.accountId, + taskListId: task.taskListId, + taskId: task.id, + ); + _reloadTaskAttachments(); + } on Object catch (error) { + _attachmentError(error); + } finally { + if (mounted) setState(() => _attachmentBusy = false); + } + } + + Future _cancelTaskUpload() async { + final task = widget.task; + if (task == null) return; + setState(() => _attachmentBusy = true); + try { + final client = ref.read( + microsoftTodoApiClientForAccountProvider(widget.accountId), + ); + if (client is! MicrosoftTodoAttachmentsApiClient) return; + await ref + .read(attachmentUploadCoordinatorProvider) + .cancelTask( + client: client as MicrosoftTodoAttachmentsApiClient, + accountId: widget.accountId, + taskListId: task.taskListId, + taskId: task.id, + ); + _reloadTaskAttachments(); + } on Object catch (error) { + _attachmentError(error); + } finally { + if (mounted) setState(() => _attachmentBusy = false); + } + } + + Future _resolveTaskUpload({required bool exists}) async { + final task = widget.task; + if (task == null) return; + final confirmed = await showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: Text(exists ? context.l10n.completed : context.l10n.retry), + content: Text(context.l10n.attachmentUploadUnresolved), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext, false), + child: Text(context.l10n.cancel), + ), + FilledButton( + onPressed: () => Navigator.pop(dialogContext, true), + child: Text(exists ? context.l10n.completed : context.l10n.retry), + ), + ], + ), + ); + if (confirmed != true || !mounted) return; + ref + .read(attachmentUploadCoordinatorProvider) + .resolveUncertainManually( + AttachmentUploadCoordinator.taskKey( + widget.accountId, + task.taskListId, + task.id, + ), + exists: exists, + ); + _reloadTaskAttachments(); + } + + Future _deleteTaskAttachment( + MicrosoftTodoAttachmentDto attachment, + ) async { + final task = widget.task; + if (task == null || + task.pendingDelete || + task.id.startsWith('local-task-')) { + return; + } + setState(() => _attachmentBusy = true); + try { + final client = ref.read( + microsoftTodoApiClientForAccountProvider(widget.accountId), + ); + if (client is! MicrosoftTodoAttachmentsApiClient) { + throw StateError('Microsoft task attachments are unavailable.'); + } + await (client as MicrosoftTodoAttachmentsApiClient).deleteTaskAttachment( + taskListId: task.taskListId, + taskId: task.id, + attachmentId: attachment.id, + ); + ref + .read(attachmentUploadCoordinatorProvider) + .attachmentRemoved( + AttachmentUploadCoordinator.taskKey( + widget.accountId, + task.taskListId, + task.id, + ), + attachment.id, + ); + _reloadTaskAttachments(); + } on Object catch (error) { + _attachmentError(error); + } finally { + if (mounted) setState(() => _attachmentBusy = false); + } + } + + void _reloadTaskAttachments() { + final task = widget.task; + if (task == null) return; + final key = ( + accountId: widget.accountId, + taskListId: task.taskListId, + taskId: task.id, + ); + ref.invalidate(microsoftTaskAttachmentsProvider(key)); + final future = ref.read(microsoftTaskAttachmentsProvider(key).future); + if (mounted) { + setState(() { + _attachmentsFuture = future; + }); + } + } + + void _attachmentError(Object error) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.exportFailed('$error'))), + ); + } + + Future _downloadTaskAttachment( + MicrosoftTodoAttachmentDto attachment, + ) async { + final task = widget.task; + final name = safeAttachmentFileName(attachment.name); + if (task == null || !attachment.isFile || name == null) return; + try { + final client = ref.read( + microsoftTodoApiClientForAccountProvider(widget.accountId), + ); + if (client is! MicrosoftTodoAttachmentsApiClient) { + throw StateError('Microsoft task attachments are unavailable.'); + } + final bytes = await (client as MicrosoftTodoAttachmentsApiClient) + .downloadTaskAttachment( + taskListId: task.taskListId, + taskId: task.id, + attachmentId: attachment.id, + ); + await BusyMaxAndroidPlatform.instance.createDocument( + suggestedName: name, + mimeType: attachment.contentType ?? 'application/octet-stream', + bytes: Uint8List.fromList(bytes), + ); + } on Object catch (error) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.exportFailed('$error'))), + ); + } + } + } + Future _export() async { try { final raw = await ref diff --git a/lib/src/app/app_bootstrap.dart b/lib/src/app/app_bootstrap.dart index 6a690a55..335d6671 100644 --- a/lib/src/app/app_bootstrap.dart +++ b/lib/src/app/app_bootstrap.dart @@ -35,6 +35,9 @@ import '../dav/nextcloud/nextcloud_sharing_service.dart'; import '../dav/nextcloud/nextcloud_trash_service.dart'; import '../dav/nextcloud/nextcloud_scheduling_service.dart'; import '../features/calendar/data/calendar_repository.dart'; +import '../features/calendar/data/microsoft_shared_calendar_service.dart'; +import '../features/schedule/presentation/attachment_upload_coordinator.dart'; +import '../features/sync/cloud_calendar_range_coverage_service.dart'; import '../features/calendar/data/calendar_collection_creation_service.dart'; import '../ical/ical_import_service.dart'; import '../features/accounts/data/accounts_repository.dart'; @@ -64,8 +67,13 @@ import '../google_tasks/http/retrying_http_client.dart'; import '../google_tasks/oauth/oauth_loopback_flow.dart'; import '../google_tasks/oauth/oauth_service.dart'; import '../google_calendar/google_calendar_api_client.dart'; +import '../google_calendar/google_calendar_models.dart'; import '../microsoft_calendar/microsoft_calendar_api_client.dart'; +import '../microsoft_calendar/microsoft_calendar_models.dart'; +import '../microsoft_calendar/microsoft_event_attachment.dart'; +import '../microsoft_todo/api/microsoft_todo_api_models.dart'; import '../microsoft_todo/api/microsoft_todo_api_client.dart'; +import '../features/tasks/domain/task_source_links.dart'; import '../microsoft_todo/api/microsoft_todo_task_remote_client.dart'; import '../microsoft_todo/oauth/microsoft_oauth_service.dart'; import '../platform/common/desktop_services.dart'; @@ -510,6 +518,16 @@ final googleCalendarApiClientForAccountProvider = ); }); +final googleEventLabelsForCalendarProvider = + FutureProvider.family< + List, + ({String accountId, String calendarId}) + >( + (ref, key) async => ref + .watch(googleCalendarApiClientForAccountProvider(key.accountId)) + .getEventLabels(key.calendarId), + ); + final microsoftTodoApiClientForAccountProvider = Provider.family((ref, accountId) { final config = ref.watch(buildConfigProvider); @@ -525,22 +543,151 @@ final microsoftTodoApiClientForAccountProvider = ); }); +final microsoftTaskLinkedResourcesProvider = + FutureProvider.family< + List, + ({String accountId, String taskListId, String taskId}) + >((ref, key) async { + final client = ref.read( + microsoftTodoApiClientForAccountProvider(key.accountId), + ); + if (client is! MicrosoftTodoLinkedResourcesApiClient) { + throw StateError('Microsoft task linked resources are unavailable.'); + } + final links = []; + final seenUrls = {}; + final seenPages = {}; + String? nextLink; + do { + final page = await (client as MicrosoftTodoLinkedResourcesApiClient) + .listLinkedResourcesPage( + taskListId: key.taskListId, + taskId: key.taskId, + nextLink: nextLink, + ); + for (final resource in page.resources) { + final uri = Uri.tryParse(resource.webUrl?.trim() ?? ''); + if (uri == null || + (uri.scheme != 'https' && uri.scheme != 'http') || + uri.host.isEmpty || + uri.userInfo.isNotEmpty || + !seenUrls.add(uri.toString())) { + continue; + } + final displayName = resource.displayName?.trim(); + final applicationName = resource.applicationName?.trim(); + links.add( + TaskSourceLink( + url: uri.toString(), + label: [ + if (displayName != null && displayName.isNotEmpty) displayName, + if (applicationName != null && + applicationName.isNotEmpty && + applicationName != displayName) + applicationName, + ].join(' · '), + ), + ); + } + nextLink = page.nextLink; + if (nextLink != null && !seenPages.add(nextLink)) { + throw StateError('Microsoft linked resource pagination repeated.'); + } + } while (nextLink != null); + return List.unmodifiable(links); + }); + +final attachmentUploadCoordinatorProvider = + ChangeNotifierProvider( + (ref) => AttachmentUploadCoordinator(), + ); + +final microsoftEventAttachmentsProvider = + FutureProvider.family< + List, + ({String accountId, String calendarId, String eventId}) + >( + (ref, key) => ref + .read(microsoftCalendarApiClientForAccountProvider(key.accountId)) + .listEventAttachments( + calendarId: key.calendarId, + eventId: key.eventId, + ), + ); + +final microsoftTaskAttachmentsProvider = + FutureProvider.family< + List, + ({String accountId, String taskListId, String taskId}) + >((ref, key) async { + final client = ref.read( + microsoftTodoApiClientForAccountProvider(key.accountId), + ); + if (client is! MicrosoftTodoAttachmentsApiClient) { + throw StateError('Microsoft task attachments are unavailable.'); + } + final items = []; + final seenPages = {}; + String? nextLink; + do { + final page = await (client as MicrosoftTodoAttachmentsApiClient) + .listTaskAttachmentsPage( + taskListId: key.taskListId, + taskId: key.taskId, + nextLink: nextLink, + ); + items.addAll(page.attachments); + nextLink = page.nextLink; + if (nextLink != null && !seenPages.add(nextLink)) { + throw const FormatException('Attachment pagination loop.'); + } + } while (nextLink != null); + return items; + }); + final microsoftCalendarApiClientForAccountProvider = Provider.family((ref, accountId) { final config = ref.watch(buildConfigProvider); + final account = ref + .watch(accountsStreamProvider) + .valueOrNull + ?.where((candidate) => candidate.id == accountId) + .firstOrNull; return MicrosoftCalendarApiClient( httpClient: ref.watch(retryingHttpClientProvider), baseUri: Uri.parse(config.microsoftGraphBaseUrl), responseTimeZone: ref.watch(localTimeZoneProvider), + accountTenantId: account?.tenantId, authorizationHeaderProvider: () => ref .read(accountTokenBrokerProvider) .authorizationHeader(BusyProvider.microsoft, accountId), + sharedCalendarAuthorizationHeaderProvider: () => ref + .read(accountTokenBrokerProvider) + .microsoftSharedCalendarAuthorizationHeader(accountId), + categoryAuthorizationHeaderProvider: () => ref + .read(accountTokenBrokerProvider) + .microsoftCategoryAuthorizationHeader(accountId), unauthorizedRefreshProvider: () => ref .read(accountTokenBrokerProvider) .recoverUnauthorized(BusyProvider.microsoft, accountId), ); }); +final microsoftMasterCategoriesProvider = + FutureProvider.family, String>( + (ref, accountId) => ref + .watch(microsoftCalendarApiClientForAccountProvider(accountId)) + .listMasterCategories(), + ); + +final microsoftCategoryAuthorizationProvider = + Provider((ref) { + final gateway = ref.watch(applicationMicrosoftOAuthServiceProvider); + return gateway is MicrosoftCategoryAuthorization + ? gateway as MicrosoftCategoryAuthorization + : null; + }); + final microsoftTodoTaskRemoteClientForAccountProvider = Provider.family((ref, accountId) { return MicrosoftTodoTaskRemoteClient( @@ -647,7 +794,7 @@ typedef CalendarSyncEngineForAccountFactory = final calendarSyncEngineForAccountFactoryProvider = Provider((ref) { return (accountId, provider) { - final client = switch (provider) { + final CloudCalendarClient client = switch (provider) { BusyProvider.microsoft => ref.read( microsoftCalendarApiClientForAccountProvider(accountId), ), @@ -846,6 +993,24 @@ final calendarRepositoryProvider = Provider((ref) { ); }); +final microsoftSharedCalendarServiceProvider = + Provider((ref) { + final gateway = ref.read(applicationMicrosoftOAuthServiceProvider); + if (gateway is! MicrosoftSharedCalendarAuthorization) { + throw StateError( + 'Microsoft shared-calendar authorization is unavailable.', + ); + } + return MicrosoftSharedCalendarService( + authorization: gateway as MicrosoftSharedCalendarAuthorization, + clientForAccount: (accountId) => + ref.read(microsoftCalendarApiClientForAccountProvider(accountId)), + repository: ref.read(calendarRepositoryProvider), + engineForAccount: ref.read(calendarSyncEngineForAccountFactoryProvider), + now: DateTime.now, + ); + }); + final calendarSourcesStreamProvider = StreamProvider>((ref) { final accounts = @@ -932,6 +1097,15 @@ final scheduleTaskListsProvider = FutureProvider>(( final scheduleRepositoryProvider = Provider((ref) { return ScheduleRepository( ref.watch(databaseProvider), + ensureCloudCoverage: (range, filters) => ref + .read(cloudCalendarRangeCoverageServiceProvider) + .ensureRange( + range.start.toUtc(), + range.end.toUtc(), + accountIds: filters.accountIds, + sourceIds: filters.sourceIds, + sourceFilterActive: filters.sourceFilterActive, + ), ensureProjectionCoverage: (range) async { final start = range.start.toUtc(); final end = range.end.toUtc(); @@ -947,6 +1121,14 @@ final scheduleRepositoryProvider = Provider((ref) { ); }); +final cloudCalendarRangeCoverageServiceProvider = + Provider((ref) { + return CloudCalendarRangeCoverageService( + database: ref.watch(databaseProvider), + engineForAccount: ref.read(calendarSyncEngineForAccountFactoryProvider), + ); + }); + final Provider activeAccountProvider = Provider((ref) { final selectedAccount = ref.watch(selectedAccountProvider); if (selectedAccount != null) { diff --git a/lib/src/calendar_providers/attachment_upload_session.dart b/lib/src/calendar_providers/attachment_upload_session.dart new file mode 100644 index 00000000..6aac5f7d --- /dev/null +++ b/lib/src/calendar_providers/attachment_upload_session.dart @@ -0,0 +1,81 @@ +/// An in-memory, item-scoped Microsoft attachment transfer. The URL is an +/// opaque credential for Outlook sessions and must never be logged or shown. +final class MicrosoftAttachmentUploadSession { + MicrosoftAttachmentUploadSession({ + required this.url, + required this.expiresAt, + required this.nextOffset, + }); + + final Uri url; + DateTime? expiresAt; + int nextOffset; + bool finalRangeMayHaveBeenSubmitted = false; + + void update(Map response, int totalBytes) { + final ranges = + response['nextExpectedRanges'] ?? response['NextExpectedRanges']; + if (ranges is! List || ranges.length != 1 || ranges.single is! String) { + throw const FormatException('Invalid attachment upload progress.'); + } + final match = RegExp( + r'^(\d+)(?:-\d*)?$', + ).firstMatch(ranges.single as String); + final offset = match == null ? null : int.tryParse(match.group(1)!); + if (offset == null || offset < 0 || offset > totalBytes) { + throw const FormatException('Invalid attachment upload progress.'); + } + nextOffset = offset; + final expiration = + response['expirationDateTime'] ?? response['ExpirationDateTime']; + if (expiration != null) { + expiresAt = DateTime.tryParse(expiration.toString())?.toUtc(); + if (expiresAt == null) { + throw const FormatException('Invalid attachment upload expiry.'); + } + } + } +} + +String attachmentIdFromUploadLocation(String? source) { + final uri = Uri.tryParse(source ?? ''); + if (uri == null || uri.scheme != 'https' || uri.userInfo.isNotEmpty) { + throw const FormatException('Missing attachment upload identity.'); + } + final segments = uri.pathSegments; + if (segments.isEmpty) { + throw const FormatException('Missing attachment upload identity.'); + } + if (segments.length >= 2 && + segments[segments.length - 2].toLowerCase() == 'attachments' && + segments.last.isNotEmpty) { + return segments.last; + } + // Outlook's documented final Location uses an OData key segment, + // /Attachments('id'), whereas To Do uses /Attachments/id. + final outlook = RegExp( + r"^Attachments\('([^']+)'\)$", + caseSensitive: false, + ).firstMatch(segments.last); + if (outlook == null) { + throw const FormatException('Missing attachment upload identity.'); + } + return outlook.group(1)!; +} + +String attachmentIdFromUploadHeaders(Map headers) => + attachmentIdFromUploadLocation( + headers.entries + .where((entry) => entry.key.toLowerCase() == 'location') + .firstOrNull + ?.value, + ); + +/// A transfer request may have reached the service. It is not safe to start +/// another attempt merely because the response or status lookup was lost. +final class MicrosoftAttachmentUploadUncertain implements Exception { + const MicrosoftAttachmentUploadUncertain(); + + @override + String toString() => 'Attachment upload outcome is unresolved.'; +} diff --git a/lib/src/calendar_providers/calendar_description.dart b/lib/src/calendar_providers/calendar_description.dart index edfbebb2..d46634bd 100644 --- a/lib/src/calendar_providers/calendar_description.dart +++ b/lib/src/calendar_providers/calendar_description.dart @@ -201,6 +201,181 @@ String calendarDescriptionToHtml( return '
${buffer.toString()}
'; } +/// The exact provider-owned subtree and the separately editable remainder. +/// If the subtree is ambiguous, callers must refuse the body mutation. +final class MicrosoftMeetingBodyParts { + const MicrosoftMeetingBodyParts({ + required this.editableHtml, + required this.meetingHtml, + this.documentPrefix = '', + this.documentSuffix = '', + }); + + final String editableHtml; + final String meetingHtml; + final String documentPrefix; + final String documentSuffix; +} + +final class MicrosoftMeetingBodyEditUnsafe implements Exception { + const MicrosoftMeetingBodyEditUnsafe(); + + @override + String toString() => + 'The online meeting information could not be safely separated from the description.'; +} + +MicrosoftMeetingBodyParts splitMicrosoftMeetingBodyHtml({ + required String originalHtml, + required String? meetingUrl, +}) { + if (meetingUrl == null || meetingUrl.isEmpty || originalHtml.isEmpty) { + return MicrosoftMeetingBodyParts( + editableHtml: originalHtml, + meetingHtml: '', + ); + } + final offset = _meetingLinkOffset(originalHtml, meetingUrl); + if (offset == null) { + throw const FormatException( + 'The online meeting block could not be identified.', + ); + } + final blocks = _balancedHtmlBlocks(originalHtml); + if (blocks == null) { + throw const FormatException('The online meeting HTML is not balanced.'); + } + final containing = blocks + .where( + (block) => + block.start <= offset && + block.end >= offset && + const {'div', 'p', 'section', 'table'}.contains(block.tag), + ) + .toList(); + final marked = containing + .where( + (block) => RegExp( + r'(?:teams|meeting|online)', + caseSensitive: false, + ).hasMatch(originalHtml.substring(block.start, block.openEnd)), + ) + .toList(); + final _HtmlBlock block; + if (marked.isNotEmpty) { + marked.sort((a, b) => a.start.compareTo(b.start)); + block = marked.first; + } else if (containing.length == 1) { + block = containing.single; + } else { + // Nested unmarked containers may mix provider details and authored text. + throw const FormatException('The online meeting block is ambiguous.'); + } + final meetingHtml = originalHtml.substring(block.start, block.end); + final bodies = blocks + .where( + (candidate) => + candidate.tag == 'body' && + candidate.openEnd <= block.start && + candidate.closeStart >= block.end, + ) + .toList(); + final body = bodies.length == 1 ? bodies.single : null; + if (bodies.length > 1) { + throw const FormatException('The online meeting document is ambiguous.'); + } + final contentStart = body?.openEnd ?? 0; + final contentEnd = body?.closeStart ?? originalHtml.length; + return MicrosoftMeetingBodyParts( + editableHtml: originalHtml + .substring(contentStart, contentEnd) + .replaceRange(block.start - contentStart, block.end - contentStart, ''), + meetingHtml: meetingHtml, + documentPrefix: body == null ? '' : originalHtml.substring(0, contentStart), + documentSuffix: body == null ? '' : originalHtml.substring(contentEnd), + ); +} + +/// Carries the exact online-meeting subtree through a user-body edit. Never +/// append an arbitrary suffix: it may contain stale user-authored content. +String preserveMicrosoftMeetingBodyHtml({ + required String editedHtml, + required String originalHtml, + required String? meetingUrl, +}) { + final parts = splitMicrosoftMeetingBodyHtml( + originalHtml: originalHtml, + meetingUrl: meetingUrl, + ); + if (parts.meetingHtml.isNotEmpty && + (editedHtml.contains(meetingUrl!) || + editedHtml.contains(escapeHtml(meetingUrl)))) { + throw const FormatException( + 'The edited body still contains the online meeting block.', + ); + } + return '${parts.documentPrefix}$editedHtml${parts.meetingHtml}' + '${parts.documentSuffix}'; +} + +final class _HtmlBlock { + _HtmlBlock(this.tag, this.start, this.openEnd); + final String tag; + final int start; + final int openEnd; + int end = -1; + int closeStart = -1; +} + +List<_HtmlBlock>? _balancedHtmlBlocks(String html) { + final blocks = <_HtmlBlock>[]; + final stack = <_HtmlBlock>[]; + final pattern = RegExp( + r'<(/?)([A-Za-z][A-Za-z0-9:-]*)\b[^>]*>', + dotAll: true, + ); + const voidTags = {'br', 'hr', 'img', 'meta', 'link', 'input', 'wbr'}; + for (final match in pattern.allMatches(html)) { + final tag = match.group(2)!.toLowerCase(); + if (match.group(1) == '/') { + if (stack.isEmpty || stack.last.tag != tag) return null; + final block = stack.removeLast(); + block.closeStart = match.start; + block.end = match.end; + } else if (!voidTags.contains(tag) && !match.group(0)!.endsWith('/>')) { + final block = _HtmlBlock(tag, match.start, match.end); + blocks.add(block); + stack.add(block); + } + } + return stack.isEmpty ? blocks : null; +} + +int? _meetingLinkOffset(String html, String meetingUrl) { + final direct = html.indexOf(meetingUrl); + if (direct >= 0) return direct; + final escaped = html.indexOf(escapeHtml(meetingUrl)); + if (escaped >= 0) return escaped; + final anchor = RegExp(r']*>', caseSensitive: false, dotAll: true); + final href = RegExp( + r'''\bhref\s*=\s*(["'])(.*?)\1''', + caseSensitive: false, + dotAll: true, + ); + for (final match in anchor.allMatches(html)) { + final encoded = href.firstMatch(match.group(0)!)?.group(2); + if (encoded == null) continue; + final decoded = decodeHtmlEntities(encoded); + if (decoded == meetingUrl) return match.start; + try { + if (Uri.decodeComponent(decoded) == meetingUrl) return match.start; + } on FormatException { + // A malformed href is not evidence of the provider-owned block. + } + } + return null; +} + String escapeHtml(String value) { return value .replaceAll('&', '&') diff --git a/lib/src/calendar_providers/calendar_mutation.dart b/lib/src/calendar_providers/calendar_mutation.dart index 44c781d2..0fe8ce08 100644 --- a/lib/src/calendar_providers/calendar_mutation.dart +++ b/lib/src/calendar_providers/calendar_mutation.dart @@ -40,6 +40,9 @@ const calendarEventCopyConfirmationRequiredKey = '_copyConfirmationRequired'; const calendarEventCopyConfirmedKey = '_copyConfirmed'; const calendarEventCopyDestinationEventIdKey = '_copyDestinationEventId'; const calendarEventSemanticBaselineKey = '__busymaxSemanticBaseline'; +const calendarEventImportIcalUidKey = '_importIcalUid'; +// Discriminator for a status-property PATCH, never an event-type mutation. +const calendarEventGoogleStatusTypeContextKey = '_googleStatusTypeContext'; enum CalendarGuestUpdatePolicy { send, doNotSend } @@ -66,6 +69,10 @@ class CalendarEventMutation { this.attendees, this.clearAttendees = false, this.colorId, + this.eventLabelId, + this.eventType, + this.googleStatusEventTypeContext, + this.googleStatusProperties, this.visibility, this.transparencyOrShowAs, this.conference, @@ -101,6 +108,12 @@ class CalendarEventMutation { final Object? attendees; final bool clearAttendees; final String? colorId; + + /// Null leaves the label untouched; empty explicitly clears it. + final String? eventLabelId; + final String? eventType; + final String? googleStatusEventTypeContext; + final Map? googleStatusProperties; final String? visibility; final String? transparencyOrShowAs; final Object? conference; diff --git a/lib/src/calendar_providers/calendar_provider_capabilities.dart b/lib/src/calendar_providers/calendar_provider_capabilities.dart index 4b2cebcb..f70e5b9d 100644 --- a/lib/src/calendar_providers/calendar_provider_capabilities.dart +++ b/lib/src/calendar_providers/calendar_provider_capabilities.dart @@ -1,5 +1,24 @@ import '../providers/busy_provider.dart'; +const microsoftPersonalTenantId = '9188040d-6c67-4c5b-b112-36a304b66dad'; + +enum MicrosoftAvailabilityAccountType { workSchool, personal, unknown } + +MicrosoftAvailabilityAccountType microsoftAvailabilityAccountType( + String? tenantId, +) { + final normalized = tenantId?.trim().toLowerCase(); + if (normalized == null || + !RegExp( + r'^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$', + ).hasMatch(normalized)) { + return MicrosoftAvailabilityAccountType.unknown; + } + return normalized == microsoftPersonalTenantId + ? MicrosoftAvailabilityAccountType.personal + : MicrosoftAvailabilityAccountType.workSchool; +} + class CalendarProviderCapabilities { const CalendarProviderCapabilities({ required this.supportsCreateCalendar, diff --git a/lib/src/calendar_providers/cloud_calendar_client.dart b/lib/src/calendar_providers/cloud_calendar_client.dart index 5fad6deb..7045e785 100644 --- a/lib/src/calendar_providers/cloud_calendar_client.dart +++ b/lib/src/calendar_providers/cloud_calendar_client.dart @@ -101,6 +101,22 @@ abstract interface class CompleteRecurringInstanceClient { }); } +/// Provider-aware private-copy import. A calendar-file import must never fall +/// back to ordinary event creation, which has different UID and invitation +/// semantics. +abstract interface class PrivateCalendarImportClient { + Future> eventsWithICalUid({ + required String calendarId, + required String iCalUid, + }); + + Future importEvent({ + required String calendarId, + required String iCalUid, + required CalendarEventMutation mutation, + }); +} + /// Optional capability for providers whose batch free/busy endpoint reports /// success or failure independently for every requested calendar. abstract interface class DetailedFreeBusyClient { diff --git a/lib/src/core/auth/account_token_broker.dart b/lib/src/core/auth/account_token_broker.dart index 129e3625..bf2bb22d 100644 --- a/lib/src/core/auth/account_token_broker.dart +++ b/lib/src/core/auth/account_token_broker.dart @@ -6,6 +6,10 @@ import '../../providers/busy_provider.dart'; abstract interface class AccountTokenBroker { Future authorizationHeader(BusyProvider provider, String accountId); + Future microsoftSharedCalendarAuthorizationHeader(String accountId); + + Future microsoftCategoryAuthorizationHeader(String accountId); + Future recoverUnauthorized(BusyProvider provider, String accountId); } @@ -18,6 +22,14 @@ final class DesktopAccountTokenBroker implements AccountTokenBroker { final OAuthService google; final MicrosoftOAuthService microsoft; + @override + Future microsoftSharedCalendarAuthorizationHeader(String accountId) => + microsoft.sharedCalendarAuthorizationHeaderForAccount(accountId); + + @override + Future microsoftCategoryAuthorizationHeader(String accountId) => + microsoft.categoryAuthorizationHeaderForAccount(accountId); + @override Future authorizationHeader(BusyProvider provider, String accountId) => switch (provider) { diff --git a/lib/src/core/auth/microsoft_graph_scopes.dart b/lib/src/core/auth/microsoft_graph_scopes.dart new file mode 100644 index 00000000..bf17e3ca --- /dev/null +++ b/lib/src/core/auth/microsoft_graph_scopes.dart @@ -0,0 +1,24 @@ +/// Compares the short and fully qualified spellings returned for Microsoft +/// Graph delegated scopes. Other resource URLs retain their distinct names. +String microsoftGraphScopeName(String scope) { + final normalized = scope.trim().toLowerCase(); + const graphPrefix = 'https://graph.microsoft.com/'; + return normalized.startsWith(graphPrefix) + ? normalized.substring(graphPrefix.length) + : normalized; +} + +bool hasMicrosoftGraphScope(Iterable granted, String required) { + final name = microsoftGraphScopeName(required); + return granted.any((scope) => microsoftGraphScopeName(scope) == name); +} + +bool hasMicrosoftGraphScopes( + Iterable granted, + Iterable required, +) { + final names = granted.map(microsoftGraphScopeName).toSet(); + return required.every( + (scope) => names.contains(microsoftGraphScopeName(scope)), + ); +} diff --git a/lib/src/core/http/retry_after.dart b/lib/src/core/http/retry_after.dart new file mode 100644 index 00000000..024fd99b --- /dev/null +++ b/lib/src/core/http/retry_after.dart @@ -0,0 +1,19 @@ +import 'dart:io'; + +/// A server retry hint. Invalid or elapsed values are not usable cooldowns. +/// No upper bound is applied to an explicit server delay. +Duration? parseHttpRetryAfter(String? value, {DateTime? now}) { + final trimmed = value?.trim(); + if (trimmed == null || trimmed.isEmpty) return null; + final seconds = int.tryParse(trimmed); + if (seconds != null) { + return seconds > 0 ? Duration(seconds: seconds) : null; + } + try { + final until = HttpDate.parse(trimmed).toUtc(); + final duration = until.difference((now ?? DateTime.now()).toUtc()); + return duration > Duration.zero ? duration : null; + } on Object { + return null; + } +} diff --git a/lib/src/core/time/stored_temporal_projection.dart b/lib/src/core/time/stored_temporal_projection.dart index 48eb7ac0..46bf209a 100644 --- a/lib/src/core/time/stored_temporal_projection.dart +++ b/lib/src/core/time/stored_temporal_projection.dart @@ -15,6 +15,30 @@ DateTime? calendarEventEndAsLocal(CalendarEvent event) => event.allDay : projectedUtc(event.rawJson, 'endUtc')?.toLocal() ?? providerDateTimeAsLocal(event.endDateTime, event.endTimeZone); +/// Uses the same resolved instants as Schedule projection when deciding which +/// cached events an authoritative UTC provider window may reconcile. All-day +/// values are dates with an exclusive end, not device-local midnight instants. +bool storedCalendarEventOverlapsUtcRange( + CalendarEvent event, + DateTime rangeStart, + DateTime rangeEnd, +) { + final start = event.allDay + ? _dateUtc(event.startDate ?? event.startDateTime) + : calendarEventStartAsLocal(event)?.toUtc(); + final end = event.allDay + ? _dateUtc(event.endDate ?? event.endDateTime) + : calendarEventEndAsLocal(event)?.toUtc(); + if (start == null) return false; + final effectiveEnd = + end ?? + start.add( + event.allDay ? const Duration(days: 1) : const Duration(minutes: 1), + ); + return effectiveEnd.isAfter(rangeStart.toUtc()) && + start.isBefore(rangeEnd.toUtc()); +} + DateTime? projectedUtc(String? json, String key) { final value = _metadata(json)?[key]; return value is String ? DateTime.tryParse(value)?.toUtc() : null; @@ -68,3 +92,8 @@ Map? _metadata(String? json) { DateTime? _date(String? value) => value == null || value.length < 10 ? null : DateTime.tryParse(value.substring(0, 10)); + +DateTime? _dateUtc(String? value) { + final date = _date(value); + return date == null ? null : DateTime.utc(date.year, date.month, date.day); +} diff --git a/lib/src/dav/mutation/dav_projection_mutations.dart b/lib/src/dav/mutation/dav_projection_mutations.dart index 30a3cc50..0d767bb3 100644 --- a/lib/src/dav/mutation/dav_projection_mutations.dart +++ b/lib/src/dav/mutation/dav_projection_mutations.dart @@ -10,6 +10,81 @@ import 'dav_mutation_patch.dart'; import '../../features/calendar/presentation/event_editor_draft.dart'; import '../nextcloud/nextcloud_scheduling_mutations.dart'; +/// Edits only URI ATTACH properties on one concrete VEVENT. Replacing the +/// component with a deep copy retains untouched binary ATTACH properties, +/// grouped properties, parameter spelling, alarms, exceptions, and VTIMEZONE +/// content exactly as parsed from the original resource. +DavMutationPatch? buildDavUriAttachmentPatch({ + required String baselineRawIcs, + required IcalComponentKey target, + String? addUrl, + String? removeUrl, +}) { + if (target.componentType != 'VEVENT' || + (addUrl == null) == (removeUrl == null)) { + throw ArgumentError('Specify one VEVENT URI attachment change.'); + } + final candidate = addUrl ?? removeUrl!; + final uri = Uri.tryParse(candidate.trim()); + if (uri == null || + uri.host.isEmpty || + uri.userInfo.isNotEmpty || + (addUrl != null + ? uri.scheme != 'https' + : uri.scheme != 'https' && uri.scheme != 'http')) { + throw const FormatException('Attachment URL is not a supported URI.'); + } + final document = IcalDocument.parse(baselineRawIcs); + final component = IcalDocumentPatcher(document).requireComponent(target); + final replacement = component.deepCopy(); + bool matchingUri(IcalProperty property) { + if (property.name != 'ATTACH' || + property.parameterValue('VALUE')?.toUpperCase() == 'BINARY' || + property.parameterValue('ENCODING')?.toUpperCase() == 'BASE64') { + return false; + } + final parsed = Uri.tryParse(property.rawValue); + return parsed != null && + (parsed.scheme == 'https' || parsed.scheme == 'http') && + parsed.host.isNotEmpty && + parsed.userInfo.isEmpty && + parsed.toString() == uri.toString(); + } + + if (addUrl != null) { + if (replacement.propertiesNamed('ATTACH').any(matchingUri)) return null; + replacement.children.add( + IcalProperty( + group: null, + name: 'ATTACH', + parameters: const [ + IcalParameter(name: 'VALUE', values: ['URI'], wasQuoted: false), + ], + rawValue: uri.toString(), + originalPhysicalLines: const [], + isDirty: true, + ), + ); + } else { + final before = replacement.children.length; + replacement.children.removeWhere( + (node) => node is IcalProperty && matchingUri(node), + ); + if (replacement.children.length == before) return null; + } + replacement.structurallyDirty = true; + return DavMutationPatch( + target: target, + scope: target.recurrenceIdKey == null + ? DavMutationScope.object + : DavMutationScope.recurrenceException, + operations: [ + DavPatchOperation.removeComponent(componentKey: target), + DavPatchOperation.addComponent(replacement), + ], + ); +} + final class DavEventMutationInput { const DavEventMutationInput({ required this.title, @@ -1112,6 +1187,15 @@ DavMutationPatch buildDavRecurringTaskCompletionPatch({ List reminderMinutes(Object? reminders) { if (reminders is! Map) return const []; final map = reminders.cast(); + if (map['davEditableRows'] case final List rows) { + if (rows.any( + (row) => + row is! Map || row['minutes'] is! int || (row['minutes'] as int) < 0, + )) { + throw const FormatException('Invalid editable DAV reminder rows.'); + } + return [for (final row in rows) row['minutes'] as int]; + } final values = []; if (map['reminderMinutesBeforeStart'] is int) { values.add(map['reminderMinutesBeforeStart']! as int); @@ -1299,6 +1383,59 @@ List _eventAlarmUpdateOperations( IcalSemanticComponent current, Object? reminders, ) { + if (reminders is Map && reminders['davEditableRows'] is List) { + final rows = reminders['davEditableRows'] as List; + final editable = _editableDisplayAlarms(current); + final editableIndexes = {for (final row in editable) row.index}; + final retained = {}; + final changes = []; + final additions = []; + for (final row in rows) { + if (row is! Map || + row['minutes'] is! int || + (row['minutes'] as int) < 0) { + throw const FormatException('Invalid editable DAV reminder row.'); + } + final minutes = row['minutes'] as int; + final originalIndex = row['originalIndex']; + if (originalIndex == null) { + additions.add(minutes); + continue; + } + if (originalIndex is! int || + !editableIndexes.contains(originalIndex) || + !retained.add(originalIndex)) { + throw const FormatException('Invalid original DAV alarm identity.'); + } + final previous = editable.singleWhere( + (entry) => entry.index == originalIndex, + ); + if (previous.minutes != minutes) { + final updated = current.alarms[originalIndex].deepCopy(); + final trigger = updated.firstProperty('TRIGGER')!; + trigger.rawValue = '-PT${minutes}M'; + trigger.isDirty = true; + changes.add( + DavPatchOperation.replaceAlarm( + alarmIndex: originalIndex, + alarm: updated, + ), + ); + } + } + final removed = editableIndexes.difference(retained).toList() + ..sort((a, b) => b.compareTo(a)); + return [ + ...changes, + for (final index in removed) + DavPatchOperation.replaceAlarm(alarmIndex: index, alarm: null), + for (var index = 0; index < additions.length; index++) + DavPatchOperation.replaceAlarm( + alarmIndex: current.alarms.length - removed.length + index, + alarm: _displayAlarm(additions[index]), + ), + ]; + } final projected = _projectedEventAlarms(reminders); if (projected != null) { return [ @@ -1406,7 +1543,10 @@ List<({int index, int minutes})> _editableDisplayAlarms( if (alarm.firstProperty('ACTION')?.rawValue.toUpperCase() != 'DISPLAY') { continue; } - final trigger = alarm.firstProperty('TRIGGER')?.rawValue.toUpperCase(); + final triggerProperty = alarm.firstProperty('TRIGGER'); + final related = triggerProperty?.parameterValue('RELATED')?.toUpperCase(); + if (related != null && related != 'START') continue; + final trigger = triggerProperty?.rawValue.toUpperCase(); final match = trigger == null ? null : RegExp(r'^-PT([0-9]+)M$').firstMatch(trigger); diff --git a/lib/src/dav/nextcloud/nextcloud_sharing_service.dart b/lib/src/dav/nextcloud/nextcloud_sharing_service.dart index d27c0963..41bdbbb1 100644 --- a/lib/src/dav/nextcloud/nextcloud_sharing_service.dart +++ b/lib/src/dav/nextcloud/nextcloud_sharing_service.dart @@ -1,3 +1,5 @@ +import 'dart:convert'; + import 'package:xml/xml.dart'; import '../dav_errors.dart'; @@ -11,10 +13,12 @@ final class NextcloudShareRecipient { required this.href, required this.label, required this.group, + this.federated = false, }); final String href; final String label; final bool group; + final bool federated; } final class NextcloudCollectionShare { @@ -43,8 +47,9 @@ final class NextcloudSharingState { final Uri? publishUrl; } -/// DAV resource sharing, never the OCS Files sharing API. Recipients can only -/// originate in a successful server principal search or current-share read. +/// DAV resource sharing, never the OCS Files sharing API. Local recipients +/// come from server principal search; a federated ID becomes a remote-user +/// principal using Nextcloud's CalDAV sharing backend representation. final class NextcloudSharingService { NextcloudSharingService(this.collections); final NextcloudCollectionService collections; @@ -140,6 +145,10 @@ final class NextcloudSharingService { final text = query.trim(); if (text.isEmpty) return const []; if (text.length > 256) throw ArgumentError('Principal search is too long'); + final federated = text.contains('@') ? _federatedRecipient(text) : null; + if (text.contains('@') && federated == null) { + throw const FormatException('Invalid federated calendar recipient.'); + } final context = await collections.openContext(); final root = context.resolve( context.service.canonicalServiceUri, @@ -164,6 +173,10 @@ final class NextcloudSharingService { } } if (targets.isEmpty || targets.length > 16) { + if (federated != null) { + _knownRecipients.add(federated.href); + return [federated]; + } throw nextcloudOperationError(403, 'DavPrincipalSearchUnavailable'); } final found = {}; @@ -223,6 +236,10 @@ final class NextcloudSharingService { if (found.length >= 20) return List.unmodifiable(found.values); } } + if (found.isEmpty && federated != null) { + _knownRecipients.add(federated.href); + return [federated]; + } return List.unmodifiable(found.values); } @@ -348,3 +365,40 @@ final class NextcloudSharingService { return collections.refreshResult(); } } + +NextcloudShareRecipient? _federatedRecipient(String value) { + final at = value.indexOf('@'); + if (at < 1 || at == value.length - 1) return null; + final user = value.substring(0, at); + final server = value.substring(at + 1); + if (user.contains(RegExp(r'[/\\\s:@?#]')) || + server.contains(RegExp(r'\s')) || + server.contains('@') || + RegExp( + r'(^|/)(?:\.{1,2}|%2e(?:%2e)?)(?:/|$)', + caseSensitive: false, + ).hasMatch(server)) { + return null; + } + final uri = Uri.tryParse( + server.startsWith('https://') ? server : 'https://$server', + ); + if (uri == null || + uri.scheme != 'https' || + uri.host.isEmpty || + !uri.host.contains('.') || + uri.host.endsWith('.') || + uri.userInfo.isNotEmpty || + uri.hasQuery || + uri.hasFragment || + uri.pathSegments.any((part) => part == '..')) { + return null; + } + final id = base64.encode(utf8.encode(value)); + return NextcloudShareRecipient._( + href: 'principal:principals/remote-users/$id', + label: value, + group: false, + federated: true, + ); +} diff --git a/lib/src/features/auth/data/auth_repository.dart b/lib/src/features/auth/data/auth_repository.dart index 259dfea8..8553171d 100644 --- a/lib/src/features/auth/data/auth_repository.dart +++ b/lib/src/features/auth/data/auth_repository.dart @@ -6,6 +6,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:logging/logging.dart'; import '../../../core/logging/redacting_logger.dart'; +import '../../../core/auth/microsoft_graph_scopes.dart'; import '../../../dav/dav_errors.dart'; import '../../../db/app_database.dart'; import '../../../features/accounts/data/accounts_repository.dart'; @@ -18,14 +19,6 @@ import '../../../microsoft_todo/oauth/microsoft_oauth_service.dart'; import '../../sync/sync_auth_error.dart'; import 'package:busymax/src/providers/busy_provider.dart'; -String _microsoftGraphScopeName(String scope) { - final normalized = scope.trim().toLowerCase(); - const graphPrefix = 'https://graph.microsoft.com/'; - return normalized.startsWith(graphPrefix) - ? normalized.substring(graphPrefix.length) - : normalized; -} - enum AuthSessionStatus { unconfigured, loading, @@ -170,6 +163,7 @@ class AuthRepository { providerAccountId: result.user.id, displayName: result.user.displayName, email: result.user.mail ?? result.user.userPrincipalName, + tenantId: result.tenantId, grantedScopes: result.tokenSet.scopes.join(' '), providerMetadata: result.user.rawJson, ); @@ -296,10 +290,11 @@ class AuthRepository { } bool _hasRequiredMicrosoftScopes(OAuthTokenSet tokenSet) { - final granted = tokenSet.scopes.map(_microsoftGraphScopeName).toSet(); - return granted.contains('user.read') && - granted.contains('tasks.readwrite') && - granted.contains('calendars.readwrite'); + return hasMicrosoftGraphScopes(tokenSet.scopes, const [ + 'User.Read', + 'Tasks.ReadWrite', + 'Calendars.ReadWrite', + ]); } Future _upsertGoogleSignedInAccount( diff --git a/lib/src/features/calendar/data/calendar_repository.dart b/lib/src/features/calendar/data/calendar_repository.dart index 9a9e81ff..0b678b07 100644 --- a/lib/src/features/calendar/data/calendar_repository.dart +++ b/lib/src/features/calendar/data/calendar_repository.dart @@ -6,6 +6,7 @@ import 'package:drift/drift.dart'; import 'package:uuid/uuid.dart'; import '../../../calendar_providers/calendar_colors.dart'; +import '../../../calendar_providers/calendar_description.dart'; import '../../../calendar_providers/calendar_create_identity.dart'; import '../../../calendar_providers/calendar_mutation.dart'; import '../../../calendar_providers/calendar_provider_capabilities.dart'; @@ -15,6 +16,7 @@ import '../../../dav/nextcloud/nextcloud_dav_context.dart'; import '../../../dav/xml/dav_xml.dart'; import '../../../calendar_providers/calendar_sync_dto.dart'; import '../../../core/time/provider_date_time.dart'; +import '../../../core/time/stored_temporal_projection.dart'; import '../../../dav/ical/ical_document.dart'; import '../../../dav/ical/ical_semantics.dart'; import '../../../dav/ical/ical_timezone.dart'; @@ -25,11 +27,13 @@ import '../../../dav/storage/dav_object_repository.dart'; import '../../../db/app_database.dart'; import '../../../google_calendar/google_calendar_mapper.dart'; import '../../../microsoft_calendar/microsoft_calendar_mapper.dart'; +import '../../../microsoft_calendar/microsoft_shared_calendar_address.dart'; import '../../accounts/domain/account_collection_creation_capabilities.dart'; import '../../notifications/notification_schedule_service.dart'; import '../../recurrence/domain/event_recurrence_codec.dart'; import '../../maps/domain/geographic_point.dart'; import '../domain/event_move_policy.dart'; +import '../domain/google_status_event.dart'; import '../domain/event_timing_policy.dart'; import '../../maps/domain/location_result.dart'; import '../../maps/data/location_resolution_repository.dart'; @@ -44,6 +48,19 @@ typedef CalendarEventRecoveryFetcher = required String eventId, }); +final class ImportedEventException { + const ImportedEventException({ + required this.originalStart, + required this.cancelled, + required this.fields, + }); + + /// The original recurrence position, not the moved start time. + final String originalStart; + final bool cancelled; + final Map fields; +} + class CalendarSourceEntity { const CalendarSourceEntity({ required this.id, @@ -180,6 +197,12 @@ class CalendarSourceCapabilities { factory CalendarSourceCapabilities.fromSource(CalendarSourceEntity source) { final available = !source.isDeleted; final writable = !source.readOnly && available; + final openedMicrosoftOwner = + source.provider == BusyProvider.microsoft && + MicrosoftSharedPrimaryCalendarAddress.parse( + source.providerCalendarId, + ) != + null; final management = calendarManagementCapabilities(source.provider); final dav = source.davEffectivePermissions; final davMetadataWritable = available && dav['canWriteProperties'] == true; @@ -207,7 +230,8 @@ class CalendarSourceCapabilities { source.primaryCalendar || source.isCurrentGoogleDataOwner ? CalendarRenameMode.global : CalendarRenameMode.personal, - BusyProvider.microsoft when writable => CalendarRenameMode.global, + BusyProvider.microsoft when writable && !openedMicrosoftOwner => + CalendarRenameMode.global, _ => CalendarRenameMode.unavailable, }; final removalMode = switch (source.provider) { @@ -218,6 +242,8 @@ class CalendarSourceCapabilities { BusyProvider.google || BusyProvider.microsoft when available && source.pendingCreate => CalendarRemovalMode.delete, + BusyProvider.microsoft when available && openedMicrosoftOwner => + CalendarRemovalMode.removeFromList, BusyProvider.google when available && !source.primaryCalendar && @@ -260,7 +286,7 @@ class CalendarSourceCapabilities { ? davMetadataWritable : source.provider == BusyProvider.google ? available - : writable), + : writable && !openedMicrosoftOwner), canChangeProviderVisibility: source.provider == BusyProvider.google && available && @@ -836,6 +862,12 @@ class CalendarRepository { return; } final removalMode = entity.capabilities.removalMode; + final openedMicrosoftOwner = + entity.provider == BusyProvider.microsoft && + MicrosoftSharedPrimaryCalendarAddress.parse( + source.providerCalendarId, + ) != + null; final createOp = await _pendingCalendarCreate(source.id); _requireCalendarSourceCapability( source, @@ -892,9 +924,18 @@ class CalendarRepository { CalendarSourcesCompanion( isDeleted: const Value(true), hidden: const Value(true), + rawJson: openedMicrosoftOwner + ? Value( + jsonEncode({ + ..._jsonMap(source.rawJson), + '_busymaxLocallyRemovedOwnerCalendar': true, + }), + ) + : const Value.absent(), updatedAtLocal: Value(now.millisecondsSinceEpoch), ), ); + if (openedMicrosoftOwner) return; await _database.pendingOpsDao.enqueue( PendingOpsCompanion.insert( id: const Uuid().v4(), @@ -930,6 +971,7 @@ class CalendarRepository { Future upsertSource({ required String accountId, required CalendarSourceDto source, + bool reopenLocallyRemovedOwner = false, }) async { final now = _now().millisecondsSinceEpoch; final id = sourceId( @@ -941,6 +983,17 @@ class CalendarRepository { final existing = await (_database.select( _database.calendarSources, )..where((row) => row.id.equals(id))).getSingleOrNull(); + final locallyRemovedOwner = + source.provider == BusyProvider.microsoft && + MicrosoftSharedPrimaryCalendarAddress.parse( + source.providerCalendarId, + ) != + null && + existing != null && + existing.isDeleted && + _jsonMap(existing.rawJson)['_busymaxLocallyRemovedOwnerCalendar'] == + true; + if (locallyRemovedOwner && !reopenLocallyRemovedOwner) return; final pendingPatchFields = existing == null ? const {} : await _pendingCalendarPatchFields(existing.id); @@ -1053,6 +1106,11 @@ class CalendarRepository { final missingSourceIds = { for (final source in sources) if (!activeProviderCalendarIds.contains(source.providerCalendarId) && + !(provider == BusyProvider.microsoft && + MicrosoftSharedPrimaryCalendarAddress.parse( + source.providerCalendarId, + ) != + null) && !pendingCreateSourceIds.contains(source.id) && !pendingCreateProviderIds.contains(source.providerCalendarId)) source.id, @@ -1761,6 +1819,19 @@ class CalendarRepository { final calendarCreateOp = await _pendingCalendarCreate(source.id); final now = _now().millisecondsSinceEpoch; final provider = BusyProviderCodec.requireStorageValue(source.provider); + if (provider == BusyProvider.google) { + validateGoogleStatusEvent( + primaryCalendar: source.primaryCalendar, + eventType: draft.eventType, + originalEventType: null, + allDay: draft.allDay, + start: draft.start, + end: draft.end, + visibility: draft.visibilityOrSensitivity, + transparency: draft.showAs, + properties: draft.googleStatusProperties, + ); + } final conferenceRequest = _conferenceRequest(draft, provider); final localEventId = 'local:${const Uuid().v4()}'; final operationId = const Uuid().v4(); @@ -1851,6 +1922,7 @@ class CalendarRepository { _json(_optimisticOrganizer(draft, provider)), ), colorId: Value(draft.colorId), + eventType: Value(draft.eventType), visibility: Value(draft.visibilityOrSensitivity), transparencyOrShowAs: Value(draft.showAs), conferenceJson: Value( @@ -1908,7 +1980,14 @@ class CalendarRepository { Future> createImportedEventsBatch({ required CalendarSourceEntity destination, - required List<({String icalUid, EventEditorDraft draft})> events, + required List< + ({ + String icalUid, + EventEditorDraft draft, + List exceptions, + }) + > + events, }) async { if (events.isEmpty) return const []; if (events.any( @@ -1931,6 +2010,43 @@ class CalendarRepository { rebuildNotifications: false, ); operationIds.add(operationId); + final masterOp = await (_database.select( + _database.pendingOps, + )..where((row) => row.id.equals(operationId))).getSingle(); + if (destination.provider == BusyProvider.google) { + final request = _jsonMap(masterOp.requestJson); + request[calendarEventImportIcalUidKey] = event.icalUid; + await (_database.update( + _database.pendingOps, + )..where((row) => row.id.equals(operationId))).write( + PendingOpsCompanion(requestJson: Value(jsonEncode(request))), + ); + } + for (final exception in event.exceptions) { + await _database + .into(_database.pendingOps) + .insert( + PendingOpsCompanion.insert( + id: const Uuid().v4(), + accountId: destination.accountId, + provider: Value(destination.provider.storageValue), + entityType: 'event', + operation: 'importException', + operationType: const Value('event.importException'), + calendarSourceId: Value(destination.id), + providerCalendarId: Value(destination.providerCalendarId), + eventId: Value(masterOp.eventId), + dependsOnOpId: Value(operationId), + requestJson: jsonEncode({ + 'originalStart': exception.originalStart, + 'cancelled': exception.cancelled, + ...exception.fields, + }), + createdAtUtc: _now().toUtc().toIso8601String(), + updatedAtUtc: _now().toUtc().toIso8601String(), + ), + ); + } await _database .into(_database.icalImportReceipts) .insert( @@ -2022,6 +2138,9 @@ class CalendarRepository { bool timingOnly = false, bool deferNotifications = false, }) async { + if (draft.descriptionEditUnsafe) { + throw const MicrosoftMeetingBodyEditUnsafe(); + } final eventId = draft.eventId; if (eventId == null) { await createLocalEvent( @@ -2040,6 +2159,11 @@ class CalendarRepository { final originalSource = await (_database.select( _database.calendarSources, )..where((row) => row.id.equals(existing.calendarSourceId))).getSingle(); + _requireMicrosoftSharedPrivateAccess( + originalSource, + existing, + operation: CalendarMutationOperation.editEvent, + ); _requireFullEventEditingAllowed(existing); _requireAttendeeManagementAllowed(existing, draft); final editBaseline = _eventEditBaseline(draft, existing); @@ -2059,6 +2183,12 @@ class CalendarRepository { draft.accountId != existing.accountId || draft.sourceId != existing.calendarSourceId || draft.providerCalendarId != existing.providerCalendarId; + if (sourceChanged && existing.eventType == 'fromGmail') { + throw CalendarMutationNotAllowed( + operation: CalendarMutationOperation.moveEvent, + sourceId: existing.calendarSourceId, + ); + } if (sourceChanged) { await _requireWritableSource( originalSource, @@ -2093,6 +2223,19 @@ class CalendarRepository { return true; } final provider = BusyProviderCodec.requireStorageValue(source.provider); + if (provider == BusyProvider.google) { + validateGoogleStatusEvent( + primaryCalendar: source.primaryCalendar, + eventType: draft.eventType, + originalEventType: existing.eventType ?? 'default', + allDay: draft.allDay, + start: draft.start, + end: draft.end, + visibility: draft.visibilityOrSensitivity, + transparency: draft.showAs, + properties: draft.googleStatusProperties, + ); + } final recurringOccurrence = _eventRequiresRecurringScope(existing); final recurringScope = draft.recurringMutationScope; if (recurringOccurrence && recurringScope == null) { @@ -2169,6 +2312,31 @@ class CalendarRepository { } } } + if (provider == BusyProvider.google && existing.eventType == 'fromGmail') { + const allowed = { + 'colorId', + 'remindersJson', + 'visibility', + 'transparencyOrShowAs', + calendarEventAttendeesField, + calendarEventGuestUpdatePolicyKey, + }; + final cleared = request[calendarEventClearFieldsKey]; + final clearsOnlyAttendees = + cleared is List && + cleared.length == 1 && + cleared.single == calendarEventAttendeesField; + if (request.keys.any( + (field) => + !allowed.contains(field) && + !(field == calendarEventClearFieldsKey && clearsOnlyAttendees), + )) { + throw CalendarMutationNotAllowed( + operation: CalendarMutationOperation.editEvent, + sourceId: source.id, + ); + } + } if (!_eventRequestHasMutation(request)) { return false; } @@ -2813,6 +2981,8 @@ class CalendarRepository { destinationProvider, ), colorId: sameProviderAccount ? draft.colorId : null, + // Google labels are scoped to one calendar, not merely one account. + eventLabelId: null, categories: destinationProvider == BusyProvider.google ? const [] : draft.categories, @@ -3113,8 +3283,14 @@ class CalendarRepository { ? wall(draft.end)!.difference(existingEnd) : Duration.zero; final raw = _jsonMap(existing.rawJson); + final existingBody = _jsonObjectMap(raw['body']); final descriptionChanged = - (draft.description ?? '') != (existing.description ?? ''); + (draft.description ?? '') != (existing.description ?? '') || + (provider == BusyProvider.microsoft && + (draft.descriptionContentType?.toLowerCase() != + existingBody['contentType']?.toString().toLowerCase() || + (draft.descriptionHtml != null && + draft.descriptionHtml != existingBody['content']))); final locationChanged = (draft.location ?? '') != (existing.location ?? '') || (provider == BusyProvider.microsoft && draft.locationChange.changed); @@ -3138,7 +3314,8 @@ class CalendarRepository { final newTimeProposalsChanged = draft.allowNewTimeProposals != raw['allowNewTimeProposals']; final rawChanged = - (provider == BusyProvider.microsoft && draft.locationChange.changed) || + (provider == BusyProvider.microsoft && + (draft.locationChange.changed || descriptionChanged)) || importanceChanged || responseRequestedChanged || hideAttendeesChanged || @@ -3226,6 +3403,7 @@ class CalendarRepository { draft, provider, existingJson: row.rawJson, + descriptionChanged: descriptionChanged, ), ), ) @@ -3400,6 +3578,11 @@ class CalendarRepository { final source = await (_database.select( _database.calendarSources, )..where((row) => row.id.equals(existing.calendarSourceId))).getSingle(); + _requireMicrosoftSharedPrivateAccess( + source, + existing, + operation: CalendarMutationOperation.deleteEvent, + ); await _requireWritableSource( source, database: _database, @@ -4012,6 +4195,123 @@ class CalendarRepository { await _onNotificationScheduleChanged?.call(); } + /// Queues a lossless URI ATTACH change through the existing DAV conditional + /// mutation path. Binary and unrecognized ATTACH properties are untouched. + Future changeNextcloudUriAttachmentReference({ + required String accountId, + required String eventId, + String? addUrl, + String? removeUrl, + }) async { + final existing = + await (_database.select(_database.calendarEvents)..where( + (row) => + row.id.equals(eventId) & + row.accountId.equals(accountId) & + row.isDeleted.equals(false), + )) + .getSingleOrNull(); + if (existing == null || existing.provider != 'nextcloud') { + throw UnsupportedError('This is not an editable Nextcloud event.'); + } + final source = + await (_database.select(_database.calendarSources)..where( + (row) => + row.id.equals(existing.calendarSourceId) & + row.accountId.equals(accountId) & + row.isDeleted.equals(false), + )) + .getSingleOrNull(); + final collectionId = source?.davCollectionId; + final uid = existing.icalUid; + if (collectionId == null || uid == null) { + throw StateError('The DAV event identity is unavailable.'); + } + if (existing.providerRecurringEventId != null && + existing.occurrenceKey != null && + existing.recurrenceIdKey == null) { + throw UnsupportedError( + 'A virtual occurrence cannot change attachment references.', + ); + } + final queue = DavPendingOperationQueue( + database: _database, + nowUtc: () => _now().toUtc(), + ); + final objectId = existing.davObjectId; + final baselineRawIcs = objectId == null + ? _pendingCreateRawIcs( + await _pendingDavCreateForProjection(existing.id) ?? + (throw StateError('The pending DAV create is unavailable.')), + ) + : await queue.editableRawIcsForObject( + accountId: accountId, + collectionId: collectionId, + objectId: objectId, + ); + final target = IcalComponentKey( + componentType: 'VEVENT', + uid: uid, + recurrenceIdKey: existing.recurrenceIdKey, + ); + final patch = buildDavUriAttachmentPatch( + baselineRawIcs: baselineRawIcs, + target: target, + addUrl: addUrl, + removeUrl: removeUrl, + ); + if (patch == null) return loadEventDetail(eventId); + final candidate = patch.applyTo(baselineRawIcs, nowUtc: _now().toUtc()); + await _database.transaction(() async { + if (objectId == null) { + final updated = await queue.updateUnsentCreate( + accountId: accountId, + collectionId: collectionId, + localProjectionId: existing.id, + patch: patch, + ); + if (!updated) { + throw StateError('The pending DAV create is no longer editable.'); + } + final component = IcalDocumentPatcher( + IcalDocument.parse(candidate), + ).requireComponent(target); + await (_database.update( + _database.calendarEvents, + )..where((row) => row.id.equals(existing.id))).write( + CalendarEventsCompanion( + attachmentsJson: Value( + jsonEncode([ + for (final property in component.propertiesNamed('ATTACH')) + property.rawValue, + ]), + ), + syncStatus: const Value('pending'), + updatedAtLocal: Value(_now().millisecondsSinceEpoch), + ), + ); + } else { + await queue.enqueueUpdate( + accountId: accountId, + collectionId: collectionId, + objectId: objectId, + patch: patch, + ); + await DavObjectRepository( + database: _database, + ).projectLocalMutationCandidate( + accountId: accountId, + collectionId: collectionId, + provider: BusyProvider.nextcloud, + objectId: objectId, + candidateRawIcs: candidate, + projectedAtUtc: _now().toUtc(), + ); + } + }); + return loadEventDetail(eventId); + } + Future _deleteLocalDavEvent( CalendarSource source, CalendarEvent existing, { @@ -4472,13 +4772,7 @@ class CalendarRepository { row.syncStatus != 'synced') { continue; } - final start = row.allDay - ? _parseDate(row.startDate) - : DateTime.tryParse(row.startDateTime ?? ''); - final end = row.allDay - ? _parseDate(row.endDate) - : DateTime.tryParse(row.endDateTime ?? ''); - if (!_intersects(rangeStart, rangeEnd, start, end)) { + if (!storedCalendarEventOverlapsUtcRange(row, rangeStart, rangeEnd)) { continue; } await (_database.update( @@ -4493,6 +4787,57 @@ class CalendarRepository { } } + /// Applies a confirmed Microsoft deletion to its existing scoped identity. + /// ID-only calendar-view markers must not create a second occurrence row. + Future markMicrosoftRemovedEventDeleted({ + required String accountId, + required String providerCalendarId, + required String providerEventId, + }) async { + final scopedSourceId = sourceId( + accountId: accountId, + provider: BusyProvider.microsoft, + providerCalendarId: providerCalendarId, + ); + await _database.transaction(() async { + final matches = + await (_database.select(_database.calendarEvents)..where( + (row) => + row.accountId.equals(accountId) & + row.calendarSourceId.equals(scopedSourceId) & + row.provider.equals(BusyProvider.microsoft.storageValue) & + row.providerEventId.equals(providerEventId) & + row.isDeleted.equals(false), + )) + .get(); + if (matches.isEmpty) return; + if (matches.length != 1) { + throw StateError('Ambiguous Microsoft event removal identity.'); + } + final match = matches.single; + if (match.syncStatus != 'synced') return; + final pending = + await (_database.select(_database.pendingOps) + ..where( + (row) => + row.accountId.equals(accountId) & + row.entityType.equals('event') & + row.eventId.equals(match.id), + ) + ..limit(1)) + .getSingleOrNull(); + if (pending != null) return; + await (_database.update( + _database.calendarEvents, + )..where((row) => row.id.equals(match.id))).write( + CalendarEventsCompanion( + isDeleted: const Value(true), + updatedAtLocal: Value(_now().millisecondsSinceEpoch), + ), + ); + }); + } + Future markExpandedRecurringMastersDeleted({ required String accountId, required BusyProvider provider, @@ -4642,6 +4987,21 @@ void _requireFullEventEditingAllowed(CalendarEvent event) { ); } +void _requireMicrosoftSharedPrivateAccess( + CalendarSource source, + CalendarEvent event, { + required CalendarMutationOperation operation, +}) { + if (MicrosoftSharedPrimaryCalendarAddress.parse(source.providerCalendarId) == + null || + event.visibility?.toLowerCase() != 'private') { + return; + } + final metadata = _jsonMap(source.rawJson); + if (metadata['canViewPrivateItems'] == true) return; + throw CalendarMutationNotAllowed(operation: operation, sourceId: source.id); +} + void _requireAttendeeManagementAllowed( CalendarEvent event, EventEditorDraft draft, @@ -4968,9 +5328,12 @@ CalendarEventsCompanion _eventPatchProjection({ }) { final rangeChanged = request.containsKey('allDay'); final rawChanged = switch (provider) { - BusyProvider.google => request.containsKey('hideAttendees'), + BusyProvider.google => + request.containsKey('hideAttendees') || + request.containsKey('eventLabelId'), BusyProvider.microsoft => request.containsKey('importance') || + request.containsKey('description') || request.containsKey('responseRequested') || request.containsKey('hideAttendees') || request.containsKey('allowNewTimeProposals') || @@ -5086,7 +5449,17 @@ Map _eventDeltaRequest( } if (draft.title.trim() != original.title) copy('title'); - if ((draft.description ?? '') != (original.description ?? '')) { + final originalBody = provider == BusyProvider.microsoft + ? _jsonObjectMap(_jsonObjectMap(original.raw)['body']) + : const {}; + final descriptionChanged = + (draft.description ?? '') != (original.description ?? '') || + (provider == BusyProvider.microsoft && + (draft.descriptionContentType?.toLowerCase() != + originalBody['contentType']?.toString().toLowerCase() || + (draft.descriptionHtml != null && + draft.descriptionHtml != originalBody['content']))); + if (descriptionChanged) { result['description'] = draft.description ?? ''; copy('descriptionContentType'); copy('descriptionHtml'); @@ -5117,6 +5490,19 @@ Map _eventDeltaRequest( } if (draft.attendeesChanged) copy(calendarEventAttendeesField); if (draft.colorId != original.colorId) copy('colorId'); + if (provider == BusyProvider.google && + draft.eventLabelChanged && + (draft.eventLabelId ?? '') != + (_jsonObjectMap(original.raw)['eventLabelId']?.toString() ?? '')) { + copy('eventLabelId'); + } + if (provider == BusyProvider.google && draft.googleStatusChanged) { + copy('googleStatusProperties'); + if (!googleStatusEventTypes.contains(original.eventType)) { + throw StateError('The existing Google status event type is unavailable.'); + } + result[calendarEventGoogleStatusTypeContextKey] = original.eventType; + } if (draft.categoriesChanged) copy('categoriesJson'); if (draft.visibilityOrSensitivity != original.visibility) { copy(provider == BusyProvider.google ? 'visibility' : 'sensitivity'); @@ -5197,6 +5583,17 @@ Map _eventRequest( if (isCreate || draft.attendeesChanged) calendarEventAttendeesField: attendees, 'colorId': draft.colorId, + if (provider == BusyProvider.google && + (isCreate ? draft.eventLabelId != null : draft.eventLabelChanged)) + 'eventLabelId': draft.eventLabelId ?? '', + if (provider == BusyProvider.google && + isCreate && + googleStatusEventTypes.contains(draft.eventType)) + 'eventType': draft.eventType, + if (provider == BusyProvider.google && + googleStatusEventTypes.contains(draft.eventType) && + (isCreate || draft.googleStatusChanged)) + 'googleStatusProperties': draft.googleStatusProperties, if (isCreate || draft.categoriesChanged) 'categoriesJson': _categoriesJson(draft, provider), 'visibility': provider == BusyProvider.google @@ -5325,6 +5722,7 @@ Object? _localAttendeesJson(EventEditorDraft draft, BusyProvider provider) { for (final attendee in draft.attendees) if (provider == BusyProvider.microsoft) { + ...attendee.rawJson, ...attendee.toMicrosoftJson(), if (attendee.responseStatus case final response? when response.isNotEmpty) @@ -5332,7 +5730,11 @@ Object? _localAttendeesJson(EventEditorDraft draft, BusyProvider provider) { } else { + ...attendee.rawJson, ...attendee.toGoogleJson(), + // The provider serializer omits false, but optimistic local state + // must override an original optional:true when changing to required. + 'optional': attendee.optional, if (attendee.self) 'self': true, if (attendee.organizer) 'organizer': true, }, @@ -5355,6 +5757,7 @@ Map _optimisticEventRaw( EventEditorDraft draft, BusyProvider provider, { String? existingJson, + bool descriptionChanged = false, }) { final raw = {..._jsonMap(existingJson)}; if (provider == BusyProvider.microsoft && draft.locationChange.changed) { @@ -5363,10 +5766,30 @@ Map _optimisticEventRaw( } switch (provider) { case BusyProvider.google: + if (googleStatusEventTypes.contains(draft.eventType)) { + raw['eventType'] = draft.eventType; + if (googleStatusPropertiesKey(draft.eventType) case final key?) { + raw[key] = draft.googleStatusProperties; + } + } + if (draft.eventLabelId case final label?) { + raw['eventLabelId'] = label; + } if (draft.hideAttendees case final hidden?) { raw['guestsCanSeeOtherGuests'] = !hidden; } case BusyProvider.microsoft: + if (descriptionChanged) { + final html = draft.descriptionHtml; + raw['body'] = + html != null || + draft.descriptionContentType?.toLowerCase() == 'html' + ? { + 'contentType': 'html', + 'content': html ?? escapeHtml(draft.description ?? ''), + } + : {'contentType': 'text', 'content': draft.description ?? ''}; + } if (draft.importance case final importance?) { raw['importance'] = importance; } @@ -5404,6 +5827,17 @@ Map _optimisticEventRawForPatch( } switch (provider) { case BusyProvider.google: + final statusKey = googleStatusPropertiesKey(draft.eventType); + if (request.containsKey('googleStatusProperties') && statusKey != null) { + raw[statusKey] = draft.googleStatusProperties; + } + if (request.containsKey('eventLabelId')) { + if (draft.eventLabelId case final label? when label.isNotEmpty) { + raw['eventLabelId'] = label; + } else { + raw.remove('eventLabelId'); + } + } if (request.containsKey('hideAttendees')) { final hidden = draft.hideAttendees; if (hidden == null) { @@ -5413,6 +5847,17 @@ Map _optimisticEventRawForPatch( } } case BusyProvider.microsoft: + if (request.containsKey('description')) { + final html = draft.descriptionHtml; + raw['body'] = + html != null || + draft.descriptionContentType?.toLowerCase() == 'html' + ? { + 'contentType': 'html', + 'content': html ?? escapeHtml(draft.description ?? ''), + } + : {'contentType': 'text', 'content': draft.description ?? ''}; + } _setOrRemoveRawField( raw, request: request, @@ -5632,6 +6077,7 @@ bool _eventRequestHasMutation(Map request) { calendarEventTimingBaselineKey, calendarEventDestinationCalendarIdKey, calendarEventDestinationSourceIdKey, + calendarEventGoogleStatusTypeContextKey, }; return request.entries.any( (entry) => @@ -5645,13 +6091,13 @@ Object _eventRemindersForProvider(Object? reminders, BusyProvider provider) { if (reminders is Map) { final map = reminders.cast(); final single = map['reminderMinutesBeforeStart']; - if (single is int && single > 0) minutes.add(single); + if (single is int && single >= 0) minutes.add(single); final overrides = map['overrides']; if (overrides is List) { for (final override in overrides) { if (override is! Map) continue; final value = override['minutes']; - if (value is int && value > 0 && !minutes.contains(value)) { + if (value is int && value >= 0 && !minutes.contains(value)) { minutes.add(value); } } @@ -5659,7 +6105,7 @@ Object _eventRemindersForProvider(Object? reminders, BusyProvider provider) { final davMinutes = map['minutes']; if (davMinutes is List) { for (final value in davMinutes.whereType()) { - if (value > 0 && !minutes.contains(value)) minutes.add(value); + if (value >= 0 && !minutes.contains(value)) minutes.add(value); } } } @@ -5701,23 +6147,3 @@ String? _date(DateTime? value) { '${value.month.toString().padLeft(2, '0')}-' '${value.day.toString().padLeft(2, '0')}'; } - -DateTime? _parseDate(String? value) { - if (value == null || value.length < 10) { - return null; - } - return DateTime.tryParse(value.substring(0, 10)); -} - -bool _intersects( - DateTime rangeStart, - DateTime rangeEnd, - DateTime? start, - DateTime? end, -) { - if (start == null) { - return false; - } - final effectiveEnd = end ?? start.add(const Duration(minutes: 1)); - return effectiveEnd.isAfter(rangeStart) && start.isBefore(rangeEnd); -} diff --git a/lib/src/features/calendar/data/cloud_calendar_sharing_service.dart b/lib/src/features/calendar/data/cloud_calendar_sharing_service.dart new file mode 100644 index 00000000..544b4511 --- /dev/null +++ b/lib/src/features/calendar/data/cloud_calendar_sharing_service.dart @@ -0,0 +1,499 @@ +import 'dart:math'; + +import '../../../core/http/request_dispatch_exception.dart'; +import '../../../google_calendar/google_calendar_api_client.dart'; +import '../../../google_calendar/google_calendar_models.dart'; +import '../../../google_calendar/google_calendar_errors.dart'; +import '../../../microsoft_calendar/microsoft_calendar_api_client.dart'; +import '../../../microsoft_calendar/microsoft_calendar_errors.dart'; +import '../../../microsoft_calendar/microsoft_calendar_models.dart'; +import '../../../microsoft_calendar/microsoft_shared_calendar_address.dart'; +import '../../../providers/busy_provider.dart'; +import 'calendar_repository.dart'; + +/// A permission row retains the provider object required for a later mutation. +/// Roles are deliberately not translated into an allegedly universal model. +final class CloudCalendarShareGrant { + const CloudCalendarShareGrant({ + required this.id, + required this.recipient, + required this.role, + required this.allowedRoles, + required this.canChange, + required this.canRevoke, + this.googleRule, + this.microsoftPermission, + }); + + final String id; + final String recipient; + final String role; + final List allowedRoles; + final bool canChange; + final bool canRevoke; + final GoogleAclRule? googleRule; + final MicrosoftCalendarPermission? microsoftPermission; +} + +final class CloudCalendarShareSnapshot { + const CloudCalendarShareSnapshot( + this.grants, { + this.refreshError, + this.outcomeUnknown = false, + }); + + final List grants; + + /// A committed mutation must not be reported as failed just because the + /// follow-up read failed. The returned grants contain the acknowledged row. + final Object? refreshError; + final bool outcomeUnknown; +} + +/// A rejected sharing write may be retried explicitly after this delay. +/// It is separate from an uncertain write that requires reconciliation. +final class CloudCalendarSharingCooldownException implements Exception { + const CloudCalendarSharingCooldownException(this.retryAfter); + + final Duration retryAfter; + + @override + String toString() => 'Calendar sharing is rate-limited. Try again later.'; +} + +typedef _ShareScope = ({ + BusyProvider provider, + String accountId, + String calendarId, +}); + +enum _ShareMutationKind { add, change, revoke } + +final class _PendingShareMutation { + _PendingShareMutation({ + required this.kind, + required this.recipient, + required this.role, + required this.grantId, + required this.baselineIds, + required this.visibleGrants, + }); + + final _ShareMutationKind kind; + final String recipient; + final String? role; + final String? grantId; + final Set baselineIds; + List visibleGrants; + bool dispatching = true; +} + +final class _ShareCooldown { + const _ShareCooldown({required this.until, required this.attempts}); + + final DateTime until; + final int attempts; +} + +final class CloudCalendarSharingService { + // A reopened native view gets a new service object. Keep uncertain intent + // scoped to the provider account and calendar until an authoritative read + // resolves it; never infer failure from a missing or failed list response. + static final Map<_ShareScope, _PendingShareMutation> _pending = {}; + static final Map<_ShareScope, _ShareCooldown> _cooldowns = {}; + CloudCalendarSharingService({ + required this.source, + this.google, + this.microsoft, + DateTime Function()? now, + }) { + _now = now ?? DateTime.now; + if (!canManageSource(source)) { + throw StateError('Calendar sharing management is unavailable.'); + } + if (source.provider == BusyProvider.google && google == null || + source.provider == BusyProvider.microsoft && microsoft == null) { + throw StateError('Calendar sharing client is unavailable.'); + } + } + + final CalendarSourceEntity source; + final GoogleCalendarApiClient? google; + final MicrosoftCalendarApiClient? microsoft; + late final DateTime Function() _now; + List _grants = const []; + bool _loaded = false; + + _ShareScope get _scope => ( + provider: source.provider, + accountId: source.accountId, + calendarId: source.providerCalendarId, + ); + + bool get hasUnresolvedOutcome => _pending.containsKey(_scope); + + Duration? get retryAfter { + final cooldown = _cooldowns[_scope]; + if (cooldown == null) return null; + final remaining = cooldown.until.difference(_now()); + return remaining > Duration.zero ? remaining : null; + } + + bool get isRateLimited => retryAfter != null; + + static bool canManageSource(CalendarSourceEntity source) => + !source.isDeleted && + !source.pendingCreate && + switch (source.provider) { + BusyProvider.google => source.accessRole == 'owner', + BusyProvider.microsoft => + source.primaryCalendar && + MicrosoftSharedPrimaryCalendarAddress.parse( + source.providerCalendarId, + ) == + null, + _ => false, + }; + + List get newGrantRoles => source.provider == BusyProvider.google + ? const [ + 'freeBusyReader', + 'reader', + 'writerWithoutPrivateAccess', + 'writer', + ] + : const ['freeBusyRead', 'limitedRead', 'read', 'write']; + + Future load() async { + final pending = _pending[_scope]; + if (pending != null && pending.dispatching) { + _grants = pending.visibleGrants; + _loaded = true; + return CloudCalendarShareSnapshot(_grants, outcomeUnknown: true); + } + try { + final grants = await _fetchGrants(); + _grants = List.unmodifiable(grants); + _loaded = true; + if (pending != null) { + pending.visibleGrants = _grants; + if (_confirmsIntent(pending, _grants) && + identical(_pending[_scope], pending)) { + _pending.remove(_scope); + } + } + return CloudCalendarShareSnapshot( + _grants, + outcomeUnknown: _pending.containsKey(_scope), + ); + } on Object catch (error) { + if (pending == null) rethrow; + _grants = pending.visibleGrants; + _loaded = true; + return CloudCalendarShareSnapshot( + _grants, + refreshError: error, + outcomeUnknown: true, + ); + } + } + + Future> _fetchGrants() async => + source.provider == BusyProvider.google + ? [ + for (final rule in await google!.listAclRules( + source.providerCalendarId, + )) + _googleGrant(rule), + ] + : [ + for (final permission + in await microsoft!.listPrimaryCalendarPermissions()) + _microsoftGrant(permission), + ]; + + Future add({ + required String recipient, + required String role, + }) async { + if (!newGrantRoles.contains(role)) { + throw ArgumentError.value(role, 'role', 'Unsupported sharing role.'); + } + final target = recipient.trim(); + if (_grants.any( + (grant) => grant.recipient.toLowerCase() == target.toLowerCase(), + )) { + throw StateError('This recipient already has a calendar grant.'); + } + final pending = _begin( + kind: _ShareMutationKind.add, + recipient: target, + role: role, + ); + late final CloudCalendarShareGrant grant; + try { + grant = source.provider == BusyProvider.google + ? _googleGrant( + await google!.addAclUser( + source.providerCalendarId, + email: target, + role: role, + ), + ) + : _microsoftGrant( + await microsoft!.addPrimaryCalendarPermission( + email: target, + role: role, + ), + ); + } on Object catch (error) { + if (_isKnownUncommittedFailure(error)) { + _finishKnownFailure(pending, error); + rethrow; + } + pending.dispatching = false; + return load(); + } + _finishAcknowledged(pending); + _grants = List.unmodifiable([ + ..._grants.where((e) => e.id != grant.id), + grant, + ]); + return _refreshAfterAcknowledgedMutation(); + } + + Future change( + CloudCalendarShareGrant grant, + String role, + ) async { + if (!grant.canChange || !grant.allowedRoles.contains(role)) { + throw ArgumentError.value(role, 'role', 'This role cannot be changed.'); + } + final pending = _begin( + kind: _ShareMutationKind.change, + recipient: grant.recipient, + role: role, + grantId: grant.id, + ); + late final CloudCalendarShareGrant updated; + try { + updated = source.provider == BusyProvider.google + ? _googleGrant( + await google!.changeAclRole( + source.providerCalendarId, + grant.googleRule!, + role, + ), + ) + : _microsoftGrant( + await microsoft!.changePrimaryCalendarPermission( + grant.microsoftPermission!, + role, + ), + ); + } on Object catch (error) { + if (_isKnownUncommittedFailure(error)) { + _finishKnownFailure(pending, error); + rethrow; + } + pending.dispatching = false; + return load(); + } + _finishAcknowledged(pending); + _grants = List.unmodifiable([ + for (final current in _grants) current.id == grant.id ? updated : current, + ]); + return _refreshAfterAcknowledgedMutation(); + } + + Future revoke( + CloudCalendarShareGrant grant, + ) async { + if (!grant.canRevoke) { + throw ArgumentError.value(grant.id, 'grant', 'Grant cannot be revoked.'); + } + final pending = _begin( + kind: _ShareMutationKind.revoke, + recipient: grant.recipient, + grantId: grant.id, + ); + try { + if (source.provider == BusyProvider.google) { + await google!.revokeAclUser( + source.providerCalendarId, + grant.googleRule!, + ); + } else { + await microsoft!.revokePrimaryCalendarPermission( + grant.microsoftPermission!, + ); + } + } on Object catch (error) { + if (_isKnownUncommittedFailure(error)) { + _finishKnownFailure(pending, error); + rethrow; + } + pending.dispatching = false; + return load(); + } + _finishAcknowledged(pending); + _grants = List.unmodifiable(_grants.where((e) => e.id != grant.id)); + return _refreshAfterAcknowledgedMutation(); + } + + Future _refreshAfterAcknowledgedMutation() async { + try { + return await load(); + } on Object catch (error) { + return CloudCalendarShareSnapshot(_grants, refreshError: error); + } + } + + _PendingShareMutation _begin({ + required _ShareMutationKind kind, + required String recipient, + String? role, + String? grantId, + }) { + if (!_loaded || _pending.containsKey(_scope)) { + throw StateError( + 'Refresh and resolve this calendar sharing change before another mutation.', + ); + } + final remaining = retryAfter; + if (remaining != null) { + throw CloudCalendarSharingCooldownException(remaining); + } + if (grantId != null && !_grants.any((grant) => grant.id == grantId)) { + throw StateError('This calendar grant is no longer in the loaded list.'); + } + final pending = _PendingShareMutation( + kind: kind, + recipient: recipient, + role: role, + grantId: grantId, + baselineIds: {for (final grant in _grants) grant.id}, + visibleGrants: _grants, + ); + _pending[_scope] = pending; + return pending; + } + + bool _confirmsIntent( + _PendingShareMutation pending, + List grants, + ) => switch (pending.kind) { + _ShareMutationKind.add => + grants + .where( + (grant) => + !pending.baselineIds.contains(grant.id) && + grant.recipient.toLowerCase() == + pending.recipient.toLowerCase() && + grant.role == pending.role, + ) + .length == + 1 && + grants + .where( + (grant) => + grant.recipient.toLowerCase() == + pending.recipient.toLowerCase(), + ) + .length == + 1, + _ShareMutationKind.change => + grants + .where( + (grant) => + grant.id == pending.grantId && grant.role == pending.role, + ) + .length == + 1, + _ShareMutationKind.revoke => !grants.any( + (grant) => grant.id == pending.grantId, + ), + }; + + bool _isKnownUncommittedFailure(Object error) => switch (error) { + RequestNotDispatchedException _ => true, + ArgumentError _ => true, + GoogleCalendarApiError e when e.isRateLimited => true, + MicrosoftCalendarApiError e when e.isRateLimited => true, + GoogleCalendarApiError e + when e.statusCode >= 400 && + e.statusCode < 500 && + !const {408, 409, 429}.contains(e.statusCode) => + true, + MicrosoftCalendarApiError e + when e.statusCode >= 400 && + e.statusCode < 500 && + !const {408, 409, 429}.contains(e.statusCode) => + true, + _ => false, + }; + + void _finishKnownFailure(_PendingShareMutation pending, Object error) { + if (!identical(_pending[_scope], pending)) return; + _pending.remove(_scope); + final serverDelay = switch (error) { + GoogleCalendarApiError e when e.isRateLimited => e.retryAfter, + MicrosoftCalendarApiError e when e.isRateLimited => e.retryAfter, + _ => null, + }; + if (error is GoogleCalendarApiError && error.isRateLimited || + error is MicrosoftCalendarApiError && error.isRateLimited) { + final attempt = (_cooldowns[_scope]?.attempts ?? 0) + 1; + // Explicit Retry-After is authoritative. Otherwise bound exponential + // backoff with positive jitter, without silently resending a write. + final baseMs = min(300000, 2000 * (1 << min(attempt - 1, 7))); + final jitterMs = (baseMs * Random().nextDouble() / 4).round(); + final delay = serverDelay ?? Duration(milliseconds: baseMs + jitterMs); + _cooldowns[_scope] = _ShareCooldown( + until: _now().add(delay), + attempts: attempt, + ); + } else { + _cooldowns.remove(_scope); + } + } + + void _finishAcknowledged(_PendingShareMutation pending) { + if (!identical(_pending[_scope], pending)) return; + _pending.remove(_scope); + _cooldowns.remove(_scope); + } + + CloudCalendarShareGrant _googleGrant(GoogleAclRule rule) { + final mutable = rule.scopeType == 'user' && rule.role != 'owner'; + return CloudCalendarShareGrant( + id: rule.id, + recipient: rule.scopeValue ?? rule.scopeType, + role: rule.role, + allowedRoles: mutable ? newGrantRoles : const [], + canChange: mutable, + canRevoke: mutable, + googleRule: rule, + ); + } + + CloudCalendarShareGrant _microsoftGrant( + MicrosoftCalendarPermission permission, + ) { + final mutable = + permission.role != 'owner' && + permission.allowedRoles.any((role) => role != 'custom'); + return CloudCalendarShareGrant( + id: permission.id, + recipient: permission.address ?? permission.name ?? permission.id, + role: permission.role, + allowedRoles: [ + for (final role in permission.allowedRoles) + if (role != 'custom') role, + ], + canChange: mutable, + canRevoke: permission.role != 'owner' && permission.isRemovable, + microsoftPermission: permission, + ); + } +} diff --git a/lib/src/features/calendar/data/microsoft_shared_calendar_service.dart b/lib/src/features/calendar/data/microsoft_shared_calendar_service.dart new file mode 100644 index 00000000..fe409695 --- /dev/null +++ b/lib/src/features/calendar/data/microsoft_shared_calendar_service.dart @@ -0,0 +1,70 @@ +import '../../../microsoft_calendar/microsoft_calendar_api_client.dart'; +import '../../../microsoft_todo/oauth/microsoft_oauth_service.dart'; +import '../../../providers/busy_provider.dart'; +import '../../sync/calendar_sync_engine.dart'; +import 'calendar_repository.dart'; + +enum MicrosoftSharedCalendarOpenOutcome { ready, rangeUnavailable } + +final class MicrosoftSharedCalendarOpenResult { + const MicrosoftSharedCalendarOpenResult({ + required this.sourceId, + required this.outcome, + }); + + final String sourceId; + final MicrosoftSharedCalendarOpenOutcome outcome; +} + +/// User-initiated consent and owner-context discovery. A successful discovery +/// remains persisted even if the initial event range cannot be downloaded. +final class MicrosoftSharedCalendarService { + const MicrosoftSharedCalendarService({ + required this.authorization, + required this.clientForAccount, + required this.repository, + required this.engineForAccount, + required this.now, + }); + + final MicrosoftSharedCalendarAuthorization authorization; + final MicrosoftCalendarApiClient Function(String) clientForAccount; + final CalendarRepository repository; + final CalendarSyncEngine Function(String, BusyProvider) engineForAccount; + final DateTime Function() now; + + Future openPrimaryCalendar({ + required String accountId, + required String owner, + }) async { + await authorization.authorizeSharedCalendarAccess(accountId); + final source = await clientForAccount( + accountId, + ).getSharedPrimaryCalendar(owner); + await repository.upsertSource( + accountId: accountId, + source: source, + reopenLocallyRemovedOwner: true, + ); + final sourceId = CalendarRepository.sourceId( + accountId: accountId, + provider: BusyProvider.microsoft, + providerCalendarId: source.providerCalendarId, + ); + try { + await engineForAccount( + accountId, + BusyProvider.microsoft, + ).retrieveMonth(now(), sourceIds: {sourceId}); + return MicrosoftSharedCalendarOpenResult( + sourceId: sourceId, + outcome: MicrosoftSharedCalendarOpenOutcome.ready, + ); + } on Object { + return MicrosoftSharedCalendarOpenResult( + sourceId: sourceId, + outcome: MicrosoftSharedCalendarOpenOutcome.rangeUnavailable, + ); + } + } +} diff --git a/lib/src/features/calendar/domain/google_status_event.dart b/lib/src/features/calendar/domain/google_status_event.dart new file mode 100644 index 00000000..d12ca25b --- /dev/null +++ b/lib/src/features/calendar/domain/google_status_event.dart @@ -0,0 +1,115 @@ +const googleStatusEventTypes = [ + 'focusTime', + 'outOfOffice', + 'workingLocation', +]; + +String? googleStatusPropertiesKey(String? eventType) => switch (eventType) { + 'focusTime' => 'focusTimeProperties', + 'outOfOffice' => 'outOfOfficeProperties', + 'workingLocation' => 'workingLocationProperties', + _ => null, +}; + +Map googleStatusPropertiesFromRaw( + String? eventType, + Object? raw, +) { + final key = googleStatusPropertiesKey(eventType); + if (key == null || raw is! Map || raw[key] is! Map) return const {}; + return Map.from(raw[key] as Map); +} + +Map defaultGoogleStatusProperties(String eventType) => + switch (eventType) { + 'focusTime' => const { + 'autoDeclineMode': 'declineNone', + 'chatStatus': 'available', + }, + 'outOfOffice' => const {'autoDeclineMode': 'declineNone'}, + 'workingLocation' => const { + 'type': 'homeOffice', + 'homeOffice': {}, + }, + _ => const {}, + }; + +/// Validates the subset documented for Google primary-calendar status events. +/// Google remains authoritative for account eligibility, which it does not +/// expose as a stable capability flag. +void validateGoogleStatusEvent({ + required bool primaryCalendar, + required String? eventType, + required String? originalEventType, + required bool allDay, + required DateTime? start, + required DateTime? end, + required String? visibility, + required String? transparency, + required Map properties, +}) { + final type = eventType ?? 'default'; + final original = originalEventType ?? 'default'; + if (originalEventType != null && type != original) { + throw UnsupportedError('Google event types cannot be changed in place.'); + } + if (type == 'default') return; + if (type == 'fromGmail' && original == 'fromGmail') return; + if (!googleStatusEventTypes.contains(type)) { + throw UnsupportedError('This Google event type cannot be created here.'); + } + if (!primaryCalendar) { + throw UnsupportedError( + 'Google status events require the primary calendar.', + ); + } + if (start == null || end == null || !end.isAfter(start)) { + throw ArgumentError('A status event needs a valid start and end.'); + } + if (type == 'workingLocation') { + if (visibility != 'public' || transparency != 'transparent') { + throw ArgumentError( + 'Working location must be public and show as available.', + ); + } + if (allDay && + DateTime.utc(end.year, end.month, end.day) != + DateTime.utc(start.year, start.month, start.day + 1)) { + throw ArgumentError('All-day working location must span one day.'); + } + final locationType = properties['type']; + if (!const [ + 'homeOffice', + 'officeLocation', + 'customLocation', + ].contains(locationType)) { + throw ArgumentError('Choose a supported working location.'); + } + if (locationType == 'customLocation' || locationType == 'officeLocation') { + final detail = properties[locationType]; + if (detail != null && detail is! Map) { + throw ArgumentError('Working location details are invalid.'); + } + } + return; + } + if (allDay || transparency != 'opaque') { + throw ArgumentError('Focus time and out of office must be timed and busy.'); + } + final mode = properties['autoDeclineMode']; + if (mode != null && + !const [ + 'declineNone', + 'declineAllConflictingInvitations', + 'declineOnlyNewConflictingInvitations', + ].contains(mode)) { + throw ArgumentError('The auto-decline setting is invalid.'); + } + if (type == 'focusTime') { + final chatStatus = properties['chatStatus']; + if (chatStatus != null && + !const ['available', 'doNotDisturb'].contains(chatStatus)) { + throw ArgumentError('The chat status is invalid.'); + } + } +} diff --git a/lib/src/features/calendar/presentation/cloud_calendar_sharing_content.dart b/lib/src/features/calendar/presentation/cloud_calendar_sharing_content.dart new file mode 100644 index 00000000..22c04ac9 --- /dev/null +++ b/lib/src/features/calendar/presentation/cloud_calendar_sharing_content.dart @@ -0,0 +1,381 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:yaru/yaru.dart'; + +import '../../../app/app_bootstrap.dart'; +import '../../../app/busymax_design.dart'; +import '../../../app/busymax_dialogs.dart'; +import '../../../l10n/l10n.dart'; +import '../../../providers/busy_provider.dart'; +import '../data/calendar_repository.dart'; +import '../data/cloud_calendar_sharing_service.dart'; +import 'cloud_calendar_sharing_labels.dart'; + +/// Ubuntu-native permission management. Android and Windows provide their own +/// platform presentations over the same provider-specific service. +class CloudCalendarSharingContent extends ConsumerStatefulWidget { + const CloudCalendarSharingContent({ + super.key, + required this.sources, + this.serviceFactory, + }); + + final List sources; + final CloudCalendarSharingService Function(CalendarSourceEntity)? + serviceFactory; + + @override + ConsumerState createState() => + _CloudCalendarSharingContentState(); +} + +class _CloudCalendarSharingContentState + extends ConsumerState { + final _recipient = TextEditingController(); + CloudCalendarSharingService? _service; + CloudCalendarShareSnapshot? _snapshot; + Object? _error; + bool _busy = false; + Timer? _cooldownTimer; + String? _newRole; + int _generation = 0; + + @override + void initState() { + super.initState(); + if (widget.sources.isNotEmpty) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) unawaited(_select(widget.sources.first)); + }); + } + } + + @override + void didUpdateWidget(covariant CloudCalendarSharingContent oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.sources.length != widget.sources.length || + oldWidget.sources.any( + (source) => !widget.sources.any((current) => current.id == source.id), + )) { + final selected = _service?.source.id; + final next = widget.sources.where((source) => source.id == selected); + if (next.isNotEmpty) { + unawaited(_select(next.first)); + } else if (widget.sources.isNotEmpty) { + unawaited(_select(widget.sources.first)); + } else { + _generation++; + _service = null; + _snapshot = null; + } + } + } + + @override + void dispose() { + _generation++; + _cooldownTimer?.cancel(); + _recipient.dispose(); + super.dispose(); + } + + Future _select(CalendarSourceEntity source) async { + final generation = ++_generation; + _cooldownTimer?.cancel(); + setState(() { + _service = null; + _snapshot = null; + _error = null; + _busy = true; + }); + try { + final service = + widget.serviceFactory?.call(source) ?? + CloudCalendarSharingService( + source: source, + google: source.provider == BusyProvider.google + ? ref.read( + googleCalendarApiClientForAccountProvider(source.accountId), + ) + : null, + microsoft: source.provider == BusyProvider.microsoft + ? ref.read( + microsoftCalendarApiClientForAccountProvider( + source.accountId, + ), + ) + : null, + ); + if (!mounted || generation != _generation) return; + setState(() { + _service = service; + _newRole = service.newGrantRoles.first; + }); + final snapshot = await service.load(); + if (!mounted || generation != _generation) return; + setState(() => _snapshot = snapshot); + } on Object catch (error) { + if (!mounted || generation != _generation) return; + setState(() => _error = error); + } finally { + if (mounted && generation == _generation) { + setState(() => _busy = false); + _scheduleCooldown(); + } + } + } + + Future _mutate( + Future Function(CloudCalendarSharingService) + action, + ) async { + final service = _service; + if (service == null || + _busy || + _snapshot == null || + _snapshot!.refreshError != null || + _snapshot!.outcomeUnknown || + service.hasUnresolvedOutcome) { + return; + } + final generation = _generation; + setState(() { + _busy = true; + _error = null; + }); + try { + final snapshot = await action(service); + if (!mounted || generation != _generation) return; + setState(() => _snapshot = snapshot); + if (snapshot.outcomeUnknown) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.nextcloudOutcomeUnknown)), + ); + } else if (snapshot.refreshError != null) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.sharingRefreshFailed)), + ); + } + } on Object catch (error) { + if (!mounted || generation != _generation) return; + setState(() => _error = error); + } finally { + if (mounted && generation == _generation) { + setState(() => _busy = false); + _scheduleCooldown(); + } + } + } + + void _scheduleCooldown() { + _cooldownTimer?.cancel(); + final service = _service; + final delay = service?.retryAfter; + if (service == null || delay == null) return; + final generation = _generation; + _cooldownTimer = Timer(delay + const Duration(milliseconds: 1), () { + if (!mounted || + generation != _generation || + !identical(_service, service)) { + return; + } + setState(() {}); + _scheduleCooldown(); + }); + } + + @override + Widget build(BuildContext context) { + final service = _service; + final grants = _snapshot?.grants; + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (widget.sources.length > 1) + BusyMaxMenuButton( + key: const Key('calendar-sharing-source'), + tooltip: + service?.source.summary ?? context.l10n.manageCalendarSharing, + entries: [ + for (final source in widget.sources) + BusyMaxMenuEntry( + value: source.id, + label: source.summary, + role: BusyMaxMenuEntryRole.radio, + selected: source.id == service?.source.id, + ), + ], + onSelected: (id) => unawaited( + _select(widget.sources.firstWhere((source) => source.id == id)), + ), + enabled: !_busy, + ), + if (_busy) const YaruCircularProgressIndicator(), + if (_error != null) ...[ + SelectableText('${context.l10n.operationFailed}: $_error'), + if (service != null) + BusyMaxPushButton.standard( + onPressed: _busy + ? null + : () => unawaited(_select(service.source)), + child: Text(context.l10n.retry), + ), + ], + if (service?.isRateLimited == true) + Text(context.l10n.sharingRateLimited), + if (_snapshot?.outcomeUnknown == true) ...[ + Text(context.l10n.nextcloudOutcomeUnknown), + if (service != null) + BusyMaxPushButton.standard( + onPressed: _busy + ? null + : () => unawaited(_select(service.source)), + child: Text(context.l10n.retry), + ), + ] else if (_snapshot?.refreshError != null) ...[ + Text(context.l10n.sharingRefreshFailed), + if (service != null) + BusyMaxPushButton.standard( + onPressed: _busy + ? null + : () => unawaited(_select(service.source)), + child: Text(context.l10n.retry), + ), + ], + if (grants != null) ...[ + ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 290), + child: ListView( + shrinkWrap: true, + children: [ + for (final grant in grants) + YaruListTile.square( + key: Key('calendar-sharing-grant-${grant.id}'), + title: Text(grant.recipient), + subtitle: Text( + calendarSharingRoleLabel(context.l10n, grant.role), + ), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (grant.canChange) + BusyMaxMenuButton( + tooltip: context.l10n.shareRole, + icon: const Icon(Icons.edit_outlined), + enabled: + !_busy && + service?.isRateLimited != true && + _snapshot?.refreshError == null && + _snapshot?.outcomeUnknown != true, + entries: [ + for (final role in grant.allowedRoles) + BusyMaxMenuEntry( + value: role, + label: calendarSharingRoleLabel( + context.l10n, + role, + ), + ), + ], + onSelected: (role) => unawaited( + _mutate((service) => service.change(grant, role)), + ), + ), + if (grant.canRevoke) + BusyMaxPushButton.standard( + onPressed: + _busy || + service?.isRateLimited == true || + _snapshot?.refreshError != null || + _snapshot?.outcomeUnknown == true + ? null + : () => unawaited(_confirmRevoke(grant)), + child: const Icon(Icons.person_remove_outlined), + ), + ], + ), + ), + ], + ), + ), + const SizedBox(height: 12), + BusyMaxGroupedList( + filled: true, + children: [ + YaruListTile.square( + title: TextField( + key: const Key('calendar-sharing-recipient'), + controller: _recipient, + enabled: !_busy && _snapshot?.outcomeUnknown != true, + keyboardType: TextInputType.emailAddress, + decoration: busyMaxGroupedTextFieldDecoration( + context, + labelText: context.l10n.shareRecipientEmail, + ), + ), + ), + ], + ), + if (service != null) + BusyMaxMenuButton( + key: ValueKey('calendar-sharing-new-role-${service.source.id}'), + tooltip: _newRole == null + ? context.l10n.shareRole + : calendarSharingRoleLabel(context.l10n, _newRole!), + enabled: !_busy && _snapshot?.outcomeUnknown != true, + entries: [ + for (final role in service.newGrantRoles) + BusyMaxMenuEntry( + value: role, + label: calendarSharingRoleLabel(context.l10n, role), + role: BusyMaxMenuEntryRole.radio, + selected: role == _newRole, + ), + ], + onSelected: (role) => setState(() => _newRole = role), + ), + Align( + alignment: Alignment.centerRight, + child: BusyMaxPushButton.standard( + key: const Key('calendar-sharing-add'), + onPressed: + _busy || + service?.isRateLimited == true || + _newRole == null || + _snapshot?.refreshError != null || + _snapshot?.outcomeUnknown == true + ? null + : () => unawaited(_add()), + child: Text(context.l10n.addCalendarShare), + ), + ), + ], + if (service == null && widget.sources.isEmpty) + Text(context.l10n.sharingPermissionUnavailable), + ], + ); + } + + Future _add() async { + final role = _newRole; + if (role == null) return; + await _mutate( + (service) => service.add(recipient: _recipient.text, role: role), + ); + if (_error == null && mounted) _recipient.clear(); + } + + Future _confirmRevoke(CloudCalendarShareGrant grant) async { + final confirmed = await showBusyMaxConfirm( + context, + title: context.l10n.nextcloudRevokeShare, + message: grant.recipient, + confirmLabel: context.l10n.nextcloudRevokeShare, + destructive: true, + ); + if (confirmed && mounted) await _mutate((service) => service.revoke(grant)); + } +} diff --git a/lib/src/features/calendar/presentation/cloud_calendar_sharing_labels.dart b/lib/src/features/calendar/presentation/cloud_calendar_sharing_labels.dart new file mode 100644 index 00000000..453381dc --- /dev/null +++ b/lib/src/features/calendar/presentation/cloud_calendar_sharing_labels.dart @@ -0,0 +1,12 @@ +import 'package:busymax/l10n/generated/app_localizations.dart'; + +String calendarSharingRoleLabel(AppLocalizations l10n, String role) => + switch (role) { + 'freeBusyReader' || 'freeBusyRead' => l10n.calendarShareFreeBusy, + 'limitedRead' => l10n.calendarShareLimitedRead, + 'reader' || 'read' => l10n.calendarShareRead, + 'writer' || 'write' => l10n.calendarShareWrite, + 'writerWithoutPrivateAccess' => l10n.calendarShareWriteWithoutPrivate, + 'owner' => l10n.calendarShareOwner, + _ => role, + }; diff --git a/lib/src/features/calendar/presentation/event_description_editor.dart b/lib/src/features/calendar/presentation/event_description_editor.dart index 8791864e..ee70d9b9 100644 --- a/lib/src/features/calendar/presentation/event_description_editor.dart +++ b/lib/src/features/calendar/presentation/event_description_editor.dart @@ -41,6 +41,8 @@ class EventDescriptionEditor extends StatefulWidget { class _EventDescriptionEditorState extends State { late final _RichDescriptionController _controller; var _notifying = false; + late String _lastEmittedText; + String? _lastEmittedHtml; bool get _supportsRichText => widget.provider == BusyProvider.microsoft; @@ -54,6 +56,10 @@ class _EventDescriptionEditorState extends State { ranges: const [], ); _controller = _RichDescriptionController(document); + _lastEmittedText = _controller.text; + _lastEmittedHtml = _supportsRichText + ? calendarDescriptionToHtml(_controller.text, _controller.ranges) + : null; _controller.addListener(_emitChanged); } @@ -74,11 +80,16 @@ class _EventDescriptionEditorState extends State { final text = _controller.text; if (_supportsRichText) { final html = calendarDescriptionToHtml(text, _controller.ranges); + if (text == _lastEmittedText && html == _lastEmittedHtml) return; + _lastEmittedText = text; + _lastEmittedHtml = html; widget.onChanged( EventDescriptionValue(text: text, contentType: 'html', html: html), ); return; } + if (text == _lastEmittedText) return; + _lastEmittedText = text; widget.onChanged(EventDescriptionValue(text: text)); } finally { _notifying = false; @@ -239,20 +250,17 @@ class _RichDescriptionController extends TextEditingController { List _ranges; String _previousText; - final _updatingRanges = false; List get ranges => List.unmodifiable(_ranges); @override set value(TextEditingValue newValue) { final oldText = text; - super.value = newValue; - if (_updatingRanges || oldText == newValue.text) { - _previousText = newValue.text; - return; + if (oldText != newValue.text) { + _ranges = _adjustRanges(_ranges, _previousText, newValue.text); } - _ranges = _adjustRanges(_ranges, _previousText, newValue.text); _previousText = newValue.text; + super.value = newValue; } bool selectionHasStyle(CalendarDescriptionInlineStyle style) { diff --git a/lib/src/features/calendar/presentation/event_editor.dart b/lib/src/features/calendar/presentation/event_editor.dart index be174714..88218e3b 100644 --- a/lib/src/features/calendar/presentation/event_editor.dart +++ b/lib/src/features/calendar/presentation/event_editor.dart @@ -7,10 +7,13 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:yaru/yaru.dart'; import '../../../app/busymax_design.dart'; +import '../../../app/app_bootstrap.dart'; import '../../../app/busymax_dialogs.dart'; import '../../../app/busymax_window_close.dart'; import '../../../calendar_providers/calendar_colors.dart'; import '../../../calendar_providers/calendar_mutation.dart'; +import '../../../calendar_providers/calendar_provider_capabilities.dart'; +import '../../../microsoft_calendar/microsoft_calendar_models.dart'; import '../../../l10n/l10n.dart'; import '../../../schedule/schedule_projection.dart'; import 'package:busymax/src/providers/busy_provider.dart'; @@ -25,8 +28,11 @@ import '../domain/event_move_policy.dart'; import 'event_description_editor.dart'; import 'event_editor_draft.dart'; import '../domain/event_timing_policy.dart'; +import '../domain/google_status_event.dart'; +import 'google_status_event_labels.dart'; import 'event_guest_delivery_dialog.dart'; import '../../../dav/presentation/nextcloud_scheduling_dialog.dart'; +import 'linux_cloud_availability_dialog.dart'; Future showBusyMaxEventEditorDialog( BuildContext context, { @@ -547,9 +553,9 @@ class _EventEditorState extends ConsumerState { YaruListTile.square( title: EventDescriptionEditor( provider: provider, - text: _draft.description, + text: _draft.editableDescription, contentType: _draft.descriptionContentType, - html: _draft.descriptionHtml, + html: _draft.editableDescriptionHtml, onChanged: (value) { setState(() { _draft = _draft.copyWith( @@ -601,11 +607,35 @@ class _EventEditorState extends ConsumerState { ), child: Text(l10n.nextcloudGuestAvailability), ), + if ((provider == BusyProvider.google || + (provider == BusyProvider.microsoft && + microsoftAvailabilityAccountType( + widget.accounts + .where( + (account) => + account.id == currentSource!.accountId, + ) + .firstOrNull + ?.tenantId, + ) == + MicrosoftAvailabilityAccountType.workSchool)) && + _draft.attendees.any( + (attendee) => !attendee.self && !attendee.organizer, + )) + BusyMaxPushButton.standard( + onPressed: () => showLinuxCloudAvailabilityDialog( + context, + draft: _draft, + calendarTimeZone: currentSource?.timeZone, + ), + child: Text(l10n.nextcloudGuestAvailability), + ), YaruExpandable( header: Text(l10n.organizationSection), expandIconSemanticLabel: l10n.organizationSection, isExpanded: _draft.categories.isNotEmpty || + _draft.eventLabelId != null || _draft.conference != null || _draft.createConference || (_draft.importance != null && @@ -640,6 +670,20 @@ class _EventEditorState extends ConsumerState { _importanceRow(), ], ), + if (provider == BusyProvider.google) + if (currentSource.primaryCalendar || + googleStatusEventTypes.contains(_draft.eventType)) + BusyMaxGroupedList( + title: l10n.googleEventType, + filled: true, + children: _googleStatusRows(), + ), + if (provider == BusyProvider.google) + BusyMaxGroupedList( + title: l10n.eventLabel, + filled: true, + children: [_googleLabelRow(currentSource)], + ), BusyMaxGroupedList( filled: true, children: [_availabilityRow(provider)], @@ -997,6 +1041,13 @@ class _EventEditorState extends ConsumerState { } void _selectCalendarSource(CalendarSourceEntity source) { + if (googleStatusEventTypes.contains(_draft.eventType) && + (source.provider != BusyProvider.google || !source.primaryCalendar)) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.googleStatusPrimaryOnly)), + ); + return; + } final currentProvider = _providerForDraft(_draft, _selectableSources); Object? adjustedRecurrence; var clearRecurrence = false; @@ -1067,6 +1118,10 @@ class _EventEditorState extends ConsumerState { clearRecurrence: clearRecurrence, clearImportance: source.provider != BusyProvider.microsoft, clearColorId: _draft.accountId != source.accountId || providerChanged, + clearEventLabelId: _draft.sourceId != source.id, + eventLabelChanged: _draft.sourceId == source.id + ? _draft.eventLabelChanged + : true, ); final scope = updated.recurringMutationScope; if (scope != null && !_supportsRecurringScopeFor(updated, source, scope)) { @@ -1376,17 +1431,7 @@ class _EventEditorState extends ConsumerState { _draft = _draft.copyWith( attendees: [ for (final item in _draft.attendees) - if (item == attendee) - EventAttendeeDraft( - email: item.email, - displayName: item.displayName, - optional: optional, - self: item.self, - organizer: item.organizer, - responseStatus: item.responseStatus, - ) - else - item, + if (item == attendee) item.withOptional(optional) else item, ], ); }); @@ -1403,34 +1448,317 @@ class _EventEditorState extends ConsumerState { }); } + List _googleStatusRows() { + final l10n = context.l10n; + final type = _draft.eventType ?? 'default'; + final values = ['default', ...googleStatusEventTypes]; + final rows = [ + BusyMaxComboRow( + title: l10n.googleEventType, + leading: const Icon(Icons.event_note_outlined), + values: values, + selected: values.contains(type) ? type : 'default', + labelFor: (value) => googleEventTypeLabel(l10n, value), + enabled: _draft.eventId == null, + onSelected: (value) => setState(() { + final start = _draft.start; + _draft = _draft.copyWith( + eventType: value, + googleStatusProperties: defaultGoogleStatusProperties(value), + showAs: value == 'workingLocation' ? 'transparent' : 'opaque', + visibilityOrSensitivity: value == 'workingLocation' + ? 'public' + : 'default', + allDay: value == 'workingLocation' ? _draft.allDay : false, + end: value == 'workingLocation' && _draft.allDay && start != null + ? DateTime(start.year, start.month, start.day + 1) + : _draft.end, + ); + }), + ), + ]; + if (!googleStatusEventTypes.contains(type)) return rows; + final properties = _draft.googleStatusProperties; + if (type == 'focusTime' || type == 'outOfOffice') { + const modes = [ + 'declineNone', + 'declineOnlyNewConflictingInvitations', + 'declineAllConflictingInvitations', + ]; + final mode = properties['autoDeclineMode']?.toString() ?? 'declineNone'; + rows.add( + BusyMaxComboRow( + title: l10n.googleDeclineInvitations, + values: modes, + selected: modes.contains(mode) ? mode : modes.first, + labelFor: (value) => googleAutoDeclineLabel(l10n, value), + onSelected: (value) => setState( + () => _draft = _draft.copyWith( + googleStatusProperties: {...properties, 'autoDeclineMode': value}, + ), + ), + ), + ); + if (type == 'focusTime') { + rows.add( + BusyMaxComboRow( + title: l10n.googleChatStatus, + values: const ['available', 'doNotDisturb'], + selected: properties['chatStatus'] == 'doNotDisturb' + ? 'doNotDisturb' + : 'available', + labelFor: (value) => value == 'doNotDisturb' + ? l10n.googleChatDoNotDisturb + : l10n.googleChatAvailable, + onSelected: (value) => setState( + () => _draft = _draft.copyWith( + googleStatusProperties: {...properties, 'chatStatus': value}, + ), + ), + ), + ); + } + rows.add( + TextFormField( + key: ValueKey('google-status-message-$type'), + initialValue: properties['declineMessage']?.toString() ?? '', + decoration: InputDecoration(labelText: l10n.googleDeclineMessage), + onChanged: (value) => setState( + () => _draft = _draft.copyWith( + googleStatusProperties: {...properties, 'declineMessage': value}, + ), + ), + ), + ); + return rows; + } + final locationType = properties['type']?.toString() ?? 'homeOffice'; + const locations = ['homeOffice', 'officeLocation', 'customLocation']; + rows.add( + BusyMaxComboRow( + title: l10n.googleWorkingLocation, + values: locations, + selected: locations.contains(locationType) + ? locationType + : locations.first, + labelFor: (value) => googleWorkingLocationLabel(l10n, value), + onSelected: (value) => setState(() { + final next = {...properties} + ..remove('homeOffice') + ..remove('officeLocation') + ..remove('customLocation'); + next['type'] = value; + next[value] = value == 'homeOffice' + ? {} + : (properties[value] is Map + ? Map.from(properties[value] as Map) + : {}); + _draft = _draft.copyWith(googleStatusProperties: next); + }), + ), + ); + if (locationType != 'homeOffice') { + final detail = properties[locationType] is Map + ? Map.from(properties[locationType] as Map) + : {}; + rows.add( + TextFormField( + key: ValueKey('google-status-location-$locationType'), + initialValue: detail['label']?.toString() ?? '', + decoration: InputDecoration(labelText: l10n.googleWorkLocationLabel), + onChanged: (value) => setState( + () => _draft = _draft.copyWith( + googleStatusProperties: { + ...properties, + locationType: {...detail, 'label': value}, + }, + ), + ), + ), + ); + } + return rows; + } + Widget _categoriesRow() { final l10n = context.l10n; - return BusyMaxCategoryEditorRow( - title: l10n.categories, - addLabel: l10n.addCategory, - categories: _draft.categories, - suggestions: - widget.categorySuggestionsByAccount[_draft.accountId] ?? - const [], - adding: _addingCategory, - inputKey: const Key('event-category-input'), - onAddPressed: () { - setState(() { - _addingCategory = true; - }); - }, - onSubmitted: _addCategory, - onCancelAdding: () { - setState(() { - _addingCategory = false; - }); + final isMicrosoft = + _providerForDraft(_draft, widget.sources) == BusyProvider.microsoft; + var hasProviderScope = false; + if (isMicrosoft) { + try { + ProviderScope.containerOf(context, listen: false); + hasProviderScope = true; + } on StateError { + // Optional account metadata is absent in standalone editor previews. + } + } + final categoryLookup = isMicrosoft && hasProviderScope + ? ref.watch(microsoftMasterCategoriesProvider(_draft.accountId)) + : null; + final masterCategories = categoryLookup?.valueOrNull; + return Column( + children: [ + BusyMaxCategoryEditorRow( + title: l10n.categories, + addLabel: l10n.addCategory, + categories: _draft.categories, + suggestions: + masterCategories + ?.map((category) => category.displayName) + .toList() ?? + widget.categorySuggestionsByAccount[_draft.accountId] ?? + const [], + adding: _addingCategory, + inputKey: const Key('event-category-input'), + onAddPressed: () { + setState(() { + _addingCategory = true; + }); + }, + onSubmitted: _addCategory, + onCancelAdding: () { + setState(() { + _addingCategory = false; + }); + }, + onDeleted: _removeCategory, + ), + if (masterCategories != null) + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + for (final category in masterCategories) + BusyMaxPushButton.standard( + onPressed: () => + _draft.categories.contains(category.displayName) + ? _removeCategory(category.displayName) + : _addCategory(category.displayName), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (microsoftCategorySwatchArgb(category.color) + case final color?) ...[ + Container(width: 12, height: 12, color: Color(color)), + const SizedBox(width: 6), + ], + Text(category.displayName), + if (_draft.categories.contains(category.displayName)) ...[ + const SizedBox(width: 4), + const Icon(Icons.check, size: 16), + ], + ], + ), + ), + ], + ) + else if (isMicrosoft && categoryLookup?.hasError == true) + BusyMaxPushButton.standard( + onPressed: () async { + try { + await ref + .read(microsoftCategoryAuthorizationProvider) + ?.authorizeCategoryAccess(_draft.accountId); + if (mounted) { + ref.invalidate( + microsoftMasterCategoriesProvider(_draft.accountId), + ); + } + } on Object { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(l10n.outlookCategoriesUnavailable)), + ); + } + } + }, + child: Text(l10n.loadOutlookCategories), + ), + ], + ); + } + + Widget _googleLabelRow(CalendarSourceEntity source) { + // The editor can also be embedded without an application scope (for + // example, a standalone preview). Metadata is optional; editing the + // event itself must remain available in that composition. + try { + ProviderScope.containerOf(context, listen: false); + } on StateError { + return BusyMaxActionRow( + title: context.l10n.eventLabel, + subtitle: _draft.eventLabelId ?? context.l10n.noneValue, + ); + } + final labels = ref.watch( + googleEventLabelsForCalendarProvider(( + accountId: source.accountId, + calendarId: source.providerCalendarId, + )), + ); + final current = _draft.eventLabelId; + return labels.when( + loading: () => BusyMaxActionRow( + title: context.l10n.eventLabel, + subtitle: current ?? context.l10n.noneValue, + onTap: null, + ), + error: (_, _) => BusyMaxActionRow( + title: context.l10n.eventLabel, + subtitle: current ?? context.l10n.noneValue, + onTap: () => ref.invalidate( + googleEventLabelsForCalendarProvider(( + accountId: source.accountId, + calendarId: source.providerCalendarId, + )), + ), + ), + data: (available) { + final byId = {for (final label in available) label.id: label}; + final values = [ + '', + ...byId.keys, + if (current != null && !byId.containsKey(current)) current, + ]; + return BusyMaxComboRow( + title: context.l10n.eventLabel, + leading: Icon( + Icons.label_outline, + color: byId[current] == null + ? null + : Color( + 0xff000000 | + int.parse( + byId[current]!.backgroundColor.substring(1), + radix: 16, + ), + ), + ), + values: values, + selected: current ?? '', + labelFor: (id) => id.isEmpty + ? context.l10n.noneValue + : byId[id]?.name ?? context.l10n.unknownEventLabel(id), + onSelected: (id) => setState(() { + _draft = id.isEmpty + ? _draft.copyWith(clearEventLabelId: true) + : _draft.copyWith(eventLabelId: id); + }), + ); }, - onDeleted: _removeCategory, ); } Widget _availabilityRow(BusyProvider provider) { - final values = provider != BusyProvider.microsoft + final statusType = provider == BusyProvider.google + ? _draft.eventType + : null; + final values = statusType == 'workingLocation' + ? const ['transparent'] + : statusType == 'focusTime' || statusType == 'outOfOffice' + ? const ['opaque'] + : provider != BusyProvider.microsoft ? const ['opaque', 'transparent'] : const ['free', 'tentative', 'busy', 'oof', 'workingElsewhere']; final selected = values.contains(_draft.showAs) @@ -1444,6 +1772,7 @@ class _EventEditorState extends ConsumerState { values: values, selected: selected, labelFor: (value) => _availabilityLabel(context, value), + enabled: values.length > 1, onSelected: (value) { setState(() { _draft = _draft.copyWith(showAs: value); @@ -1453,7 +1782,10 @@ class _EventEditorState extends ConsumerState { } Widget _visibilityRow(BusyProvider provider) { - final values = provider != BusyProvider.microsoft + final values = + provider == BusyProvider.google && _draft.eventType == 'workingLocation' + ? const ['public'] + : provider != BusyProvider.microsoft ? const ['default', 'public', 'private', 'confidential'] : const ['normal', 'personal', 'private', 'confidential']; final selected = values.contains(_draft.visibilityOrSensitivity) @@ -1465,6 +1797,7 @@ class _EventEditorState extends ConsumerState { values: values, selected: selected, labelFor: (value) => _visibilityLabel(context, value), + enabled: values.length > 1, onSelected: (value) { setState(() { _draft = _draft.copyWith(visibilityOrSensitivity: value); diff --git a/lib/src/features/calendar/presentation/event_editor_draft.dart b/lib/src/features/calendar/presentation/event_editor_draft.dart index 84c4bcc9..201ab064 100644 --- a/lib/src/features/calendar/presentation/event_editor_draft.dart +++ b/lib/src/features/calendar/presentation/event_editor_draft.dart @@ -1,8 +1,10 @@ import '../../../core/time/provider_date_time.dart'; +import '../../../calendar_providers/calendar_description.dart'; import '../../maps/domain/geographic_point.dart'; import '../../maps/domain/location_result.dart'; import '../../../providers/busy_provider.dart'; import '../data/calendar_event_detail.dart'; +import '../domain/google_status_event.dart'; enum RecurringEventMutationScope { entireSeries, @@ -21,6 +23,7 @@ class EventAttendeeDraft { this.self = false, this.organizer = false, this.responseStatus, + this.rawJson = const {}, }); factory EventAttendeeDraft.fromJson(Map json) { @@ -50,6 +53,7 @@ class EventAttendeeDraft { json['responseStatus']?.toString() ?? parameters['PARTSTAT'] ?? 'NEEDS-ACTION', + rawJson: Map.unmodifiable(json), ); } final emailAddress = switch (json['emailAddress']) { @@ -76,6 +80,7 @@ class EventAttendeeDraft { final Map value => value['response']?.toString(), _ => null, }, + rawJson: Map.unmodifiable(json), ); } @@ -86,9 +91,26 @@ class EventAttendeeDraft { final bool organizer; final String? responseStatus; + /// Provider-owned attendee metadata retained when changing only the role. + final Map rawJson; + + EventAttendeeDraft withOptional(bool value) => EventAttendeeDraft( + email: email, + displayName: displayName, + optional: value, + self: self, + organizer: organizer, + responseStatus: responseStatus, + rawJson: rawJson, + ); + Map toGoogleJson() { return { 'email': email, + if (rawJson['additionalGuests'] is int) + 'additionalGuests': rawJson['additionalGuests'], + if (rawJson['comment'] is String) 'comment': rawJson['comment'], + if (rawJson['resource'] is bool) 'resource': rawJson['resource'], if (displayName != null && displayName!.isNotEmpty) 'displayName': displayName, if (optional) 'optional': true, @@ -140,6 +162,8 @@ class EventEditorDraft { this.originalDetail, this.providerRecurringEventId, this.eventType, + this.googleStatusProperties = const {}, + this.googleStatusChanged = false, this.recurringMutationScope, this.start, this.end, @@ -152,6 +176,7 @@ class EventEditorDraft { this.description, this.descriptionContentType, this.descriptionHtml, + this.descriptionEditUnsafe = false, this.recurrence, this.recurrenceChanged = false, this.reminders, @@ -162,6 +187,8 @@ class EventEditorDraft { this.showAs, this.visibilityOrSensitivity, this.colorId, + this.eventLabelId, + this.eventLabelChanged = false, this.categories = const [], this.categoriesChanged = false, this.createConference = false, @@ -227,6 +254,9 @@ class EventEditorDraft { providerCalendarId: detail.providerCalendarId, providerRecurringEventId: detail.recurringMutationSeriesId, eventType: detail.eventType, + googleStatusProperties: detail.provider == BusyProvider.google + ? googleStatusPropertiesFromRaw(detail.eventType, raw) + : const {}, title: detail.title, allDay: detail.allDay, start: _eventEditorDateTime( @@ -258,6 +288,9 @@ class EventEditorDraft { showAs: detail.transparencyOrShowAs, visibilityOrSensitivity: detail.visibility, colorId: detail.colorId, + eventLabelId: detail.provider == BusyProvider.google + ? raw['eventLabelId']?.toString() + : null, categories: _jsonStringList(detail.categories), conference: detail.conference, responseRequested: _jsonBool(raw['responseRequested']), @@ -286,6 +319,7 @@ class EventEditorDraft { GeographicPoint? locationPoint, String? providerRecurringEventId, String? eventType, + Map googleStatusProperties = const {}, RecurringEventMutationScope? recurringMutationScope, String? description, String? descriptionContentType, @@ -299,6 +333,7 @@ class EventEditorDraft { String? showAs, String? visibilityOrSensitivity, String? colorId, + String? eventLabelId, List categories = const [], bool createConference = false, Object? conference, @@ -316,6 +351,7 @@ class EventEditorDraft { providerCalendarId: providerCalendarId, providerRecurringEventId: providerRecurringEventId, eventType: eventType, + googleStatusProperties: googleStatusProperties, recurringMutationScope: recurringMutationScope, title: title, allDay: allDay, @@ -336,6 +372,7 @@ class EventEditorDraft { showAs: showAs, visibilityOrSensitivity: visibilityOrSensitivity, colorId: colorId, + eventLabelId: eventLabelId, categories: categories, createConference: createConference, conference: conference, @@ -351,6 +388,8 @@ class EventEditorDraft { final CalendarEventDetail? originalDetail; final String? providerRecurringEventId; final String? eventType; + final Map googleStatusProperties; + final bool googleStatusChanged; final RecurringEventMutationScope? recurringMutationScope; final String accountId; final String sourceId; @@ -361,6 +400,28 @@ class EventEditorDraft { final DateTime? end; final String? startTimeZone; final String? endTimeZone; + + /// Resolves the editor's civil wall times in their provider zones before + /// passing an interval to cloud availability endpoints. + ({DateTime start, DateTime end})? cloudAvailabilityInterval({ + String? calendarTimeZone, + }) { + final wallStart = start; + final wallEnd = end; + if (wallStart == null || wallEnd == null) return null; + final dateOnlyZone = allDay ? calendarTimeZone : null; + return ( + start: providerWallTimeToInstant( + wallStart, + startTimeZone ?? dateOnlyZone, + ), + end: providerWallTimeToInstant( + wallEnd, + endTimeZone ?? startTimeZone ?? dateOnlyZone, + ), + ); + } + final String? location; final String? originalLocation; final GeographicPoint? locationPoint; @@ -370,6 +431,60 @@ class EventEditorDraft { final String? description; final String? descriptionContentType; final String? descriptionHtml; + + /// An attempted body edit must not enter the local queue when Graph's + /// provider-owned online-meeting subtree cannot be isolated exactly. + final bool descriptionEditUnsafe; + + String? get _microsoftMeetingUrl { + if (originalDetail?.provider != BusyProvider.microsoft) return null; + final original = _jsonMap(originalDetail?.raw); + final onlineMeeting = _jsonMapOrNull(original['onlineMeeting']); + final current = conference is Map ? conference as Map : null; + return current?['joinUrl']?.toString() ?? + onlineMeeting?['joinUrl']?.toString() ?? + original['onlineMeetingUrl']?.toString(); + } + + bool get _hasMicrosoftOnlineMeeting { + if (originalDetail?.provider != BusyProvider.microsoft) return false; + final original = _jsonMap(originalDetail?.raw); + return original['isOnlineMeeting'] == true || + _microsoftMeetingUrl?.isNotEmpty == true; + } + + MicrosoftMeetingBodyParts? get _originalMeetingBodyParts { + final url = _microsoftMeetingUrl; + if (url == null || url.isEmpty) return null; + final body = _jsonMapOrNull(_jsonMap(originalDetail?.raw)['body']); + if (body?['contentType']?.toString().toLowerCase() != 'html') return null; + try { + return splitMicrosoftMeetingBodyHtml( + originalHtml: body?['content']?.toString() ?? '', + meetingUrl: url, + ); + } on FormatException { + return null; + } + } + + bool get _descriptionIsOriginal { + final body = _jsonMapOrNull(_jsonMap(originalDetail?.raw)['body']); + return description == originalDetail?.description && + descriptionHtml == body?['content']; + } + + String? get editableDescription { + final parts = _descriptionIsOriginal ? _originalMeetingBodyParts : null; + return parts == null + ? description + : htmlCalendarDescriptionToPlainText(parts.editableHtml); + } + + String? get editableDescriptionHtml => + (_descriptionIsOriginal ? _originalMeetingBodyParts : null) + ?.editableHtml ?? + descriptionHtml; final Object? recurrence; /// True only after the editor deliberately changes the hydrated value. @@ -386,6 +501,10 @@ class EventEditorDraft { final String? showAs; final String? visibilityOrSensitivity; final String? colorId; + final String? eventLabelId; + + /// A null label with this flag set is an explicit removal. + final bool eventLabelChanged; final List categories; /// True only after the editor deliberately changes the hydrated list. @@ -447,6 +566,11 @@ class EventEditorDraft { String? showAs, String? visibilityOrSensitivity, String? colorId, + String? eventLabelId, + String? eventType, + Map? googleStatusProperties, + bool? googleStatusChanged, + bool? eventLabelChanged, List? categories, bool? categoriesChanged, bool? createConference, @@ -465,6 +589,7 @@ class EventEditorDraft { bool clearShowAs = false, bool clearVisibilityOrSensitivity = false, bool clearColorId = false, + bool clearEventLabelId = false, bool clearConference = false, bool clearRecurringMutationScope = false, }) { @@ -475,11 +600,77 @@ class EventEditorDraft { final updatedLocation = matchesOriginalLocation ? originalLocation : candidateLocation; + final descriptionEdited = + clearDescription || + (description != null && description != this.description) || + (descriptionHtml != null && descriptionHtml != this.descriptionHtml); + var nextDescriptionContentType = clearDescription + ? null + : descriptionContentType ?? this.descriptionContentType; + var nextDescriptionHtml = clearDescription + ? null + : descriptionHtml ?? this.descriptionHtml; + var nextDescriptionEditUnsafe = descriptionEditUnsafe; + final originalBody = _jsonMapOrNull( + _jsonMapOrNull(originalDetail?.raw)?['body'], + ); + if (originalDetail?.provider == BusyProvider.microsoft && + descriptionHtml == null && + !clearDescription && + description == originalDetail?.description) { + nextDescriptionContentType = originalBody?['contentType']?.toString(); + if (nextDescriptionContentType?.toLowerCase() == 'html') { + nextDescriptionHtml = originalBody?['content']?.toString(); + } else { + nextDescriptionHtml = null; + } + } else if (originalDetail?.provider == BusyProvider.microsoft && + descriptionEdited) { + final meetingUrl = _microsoftMeetingUrl; + if (descriptionHtml != null || _hasMicrosoftOnlineMeeting) { + final editedHtml = + descriptionHtml ?? + calendarDescriptionToHtml( + clearDescription ? '' : description ?? this.description ?? '', + const [], + ); + try { + if (_hasMicrosoftOnlineMeeting && + (meetingUrl == null || meetingUrl.isEmpty)) { + throw const FormatException( + 'The online meeting URL is unavailable.', + ); + } + nextDescriptionHtml = preserveMicrosoftMeetingBodyHtml( + editedHtml: editedHtml, + originalHtml: originalBody?['content']?.toString() ?? '', + meetingUrl: meetingUrl, + ); + nextDescriptionEditUnsafe = false; + } on FormatException { + nextDescriptionHtml = editedHtml; + nextDescriptionEditUnsafe = true; + } + nextDescriptionContentType = 'html'; + } else { + // Plain edits are authoritative; the old HTML must not override them. + nextDescriptionHtml = null; + nextDescriptionContentType = 'text'; + nextDescriptionEditUnsafe = false; + } + } return EventEditorDraft( eventId: eventId, originalDetail: originalDetail, providerRecurringEventId: providerRecurringEventId, - eventType: eventType, + eventType: eventType ?? this.eventType, + googleStatusProperties: + googleStatusProperties ?? this.googleStatusProperties, + googleStatusChanged: + googleStatusChanged ?? + (this.googleStatusChanged || + googleStatusProperties != null || + eventType != null), recurringMutationScope: clearRecurringMutationScope ? null : recurringMutationScope ?? this.recurringMutationScope, @@ -503,12 +694,9 @@ class EventEditorDraft { ? const LocationChange.clear() : const LocationChange.unchanged()), description: clearDescription ? null : description ?? this.description, - descriptionContentType: clearDescription - ? null - : descriptionContentType ?? this.descriptionContentType, - descriptionHtml: clearDescription - ? null - : descriptionHtml ?? this.descriptionHtml, + descriptionContentType: nextDescriptionContentType, + descriptionHtml: nextDescriptionHtml, + descriptionEditUnsafe: nextDescriptionEditUnsafe, recurrence: clearRecurrence ? null : recurrence ?? this.recurrence, recurrenceChanged: recurrenceChanged ?? @@ -526,6 +714,12 @@ class EventEditorDraft { ? null : visibilityOrSensitivity ?? this.visibilityOrSensitivity, colorId: clearColorId ? null : colorId ?? this.colorId, + eventLabelId: clearEventLabelId + ? null + : eventLabelId ?? this.eventLabelId, + eventLabelChanged: + eventLabelChanged ?? + (this.eventLabelChanged || eventLabelId != null || clearEventLabelId), categories: categories ?? this.categories, categoriesChanged: categoriesChanged ?? (this.categoriesChanged || categories != null), @@ -547,6 +741,8 @@ class EventEditorDraft { other.originalDetail == originalDetail && other.providerRecurringEventId == providerRecurringEventId && other.eventType == eventType && + other.googleStatusProperties == googleStatusProperties && + other.googleStatusChanged == googleStatusChanged && other.recurringMutationScope == recurringMutationScope && other.accountId == accountId && other.sourceId == sourceId && @@ -564,6 +760,7 @@ class EventEditorDraft { other.description == description && other.descriptionContentType == descriptionContentType && other.descriptionHtml == descriptionHtml && + other.descriptionEditUnsafe == descriptionEditUnsafe && other.recurrence == recurrence && other.recurrenceChanged == recurrenceChanged && other.reminders == reminders && @@ -574,6 +771,8 @@ class EventEditorDraft { other.showAs == showAs && other.visibilityOrSensitivity == visibilityOrSensitivity && other.colorId == colorId && + other.eventLabelId == eventLabelId && + other.eventLabelChanged == eventLabelChanged && _listEquals(other.categories, categories) && other.categoriesChanged == categoriesChanged && other.createConference == createConference && @@ -591,6 +790,8 @@ class EventEditorDraft { originalDetail, providerRecurringEventId, eventType, + googleStatusProperties, + googleStatusChanged, recurringMutationScope, accountId, sourceId, @@ -608,6 +809,7 @@ class EventEditorDraft { description, descriptionContentType, descriptionHtml, + descriptionEditUnsafe, recurrence, recurrenceChanged, reminders, @@ -618,6 +820,8 @@ class EventEditorDraft { showAs, visibilityOrSensitivity, colorId, + eventLabelId, + eventLabelChanged, Object.hashAll(categories), categoriesChanged, createConference, diff --git a/lib/src/features/calendar/presentation/google_status_event_labels.dart b/lib/src/features/calendar/presentation/google_status_event_labels.dart new file mode 100644 index 00000000..56fd822e --- /dev/null +++ b/lib/src/features/calendar/presentation/google_status_event_labels.dart @@ -0,0 +1,65 @@ +import '../../../../l10n/generated/app_localizations.dart'; + +String googleEventTypeLabel(AppLocalizations l10n, String? type) => + switch (type) { + 'focusTime' => l10n.googleFocusTime, + 'outOfOffice' => l10n.googleOutOfOffice, + 'workingLocation' => l10n.googleWorkingLocation, + _ => l10n.googleRegularEvent, + }; + +String googleAutoDeclineLabel(AppLocalizations l10n, String mode) => + switch (mode) { + 'declineAllConflictingInvitations' => l10n.googleDeclineAll, + 'declineOnlyNewConflictingInvitations' => l10n.googleDeclineNew, + _ => l10n.googleDeclineNone, + }; + +String googleWorkingLocationLabel(AppLocalizations l10n, String type) => + switch (type) { + 'officeLocation' => l10n.googleWorkAtOffice, + 'customLocation' => l10n.googleWorkAtCustomLocation, + _ => l10n.googleWorkAtHome, + }; + +List googleStatusDetailLines( + AppLocalizations l10n, + String? eventType, + Map properties, +) { + if (eventType != 'focusTime' && + eventType != 'outOfOffice' && + eventType != 'workingLocation') { + return const []; + } + final lines = [googleEventTypeLabel(l10n, eventType)]; + if (eventType == 'workingLocation') { + final type = properties['type']?.toString() ?? 'homeOffice'; + final detail = properties[type]; + final label = detail is Map ? detail['label']?.toString() : null; + lines.add( + label == null || label.trim().isEmpty + ? googleWorkingLocationLabel(l10n, type) + : '${googleWorkingLocationLabel(l10n, type)} · $label', + ); + } else { + if (properties['autoDeclineMode'] case final mode? when mode is String) { + lines.add( + '${l10n.googleDeclineInvitations}: ' + '${googleAutoDeclineLabel(l10n, mode)}', + ); + } + final chat = properties['chatStatus']; + if (eventType == 'focusTime' && chat is String) { + lines.add( + '${l10n.googleChatStatus}: ' + '${chat == 'doNotDisturb' ? l10n.googleChatDoNotDisturb : l10n.googleChatAvailable}', + ); + } + if (properties['declineMessage'] case final message? + when message is String && message.trim().isNotEmpty) { + lines.add('${l10n.googleDeclineMessage}: $message'); + } + } + return lines; +} diff --git a/lib/src/features/calendar/presentation/linux_cloud_availability_dialog.dart b/lib/src/features/calendar/presentation/linux_cloud_availability_dialog.dart new file mode 100644 index 00000000..b7d4d7fa --- /dev/null +++ b/lib/src/features/calendar/presentation/linux_cloud_availability_dialog.dart @@ -0,0 +1,141 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:intl/intl.dart'; +import 'package:yaru/yaru.dart'; + +import '../../../app/app_bootstrap.dart'; +import '../../../app/busymax_design.dart'; +import '../../../app/busymax_dialogs.dart'; +import '../../../calendar_providers/calendar_sync_dto.dart'; +import '../../../calendar_providers/cloud_calendar_client.dart'; +import '../../../core/logging/redacting_logger.dart'; +import '../../../l10n/l10n.dart'; +import '../../../l10n/time_format_scope.dart'; +import 'event_editor_draft.dart'; + +Future showLinuxCloudAvailabilityDialog( + BuildContext context, { + required EventEditorDraft draft, + String? calendarTimeZone, +}) => showBusyMaxModalDialog( + context, + barrierDismissible: false, + builder: (_) => _LinuxCloudAvailabilityDialog( + draft: draft, + calendarTimeZone: calendarTimeZone, + ), +); + +class _LinuxCloudAvailabilityDialog extends ConsumerStatefulWidget { + const _LinuxCloudAvailabilityDialog({ + required this.draft, + this.calendarTimeZone, + }); + + final EventEditorDraft draft; + final String? calendarTimeZone; + + @override + ConsumerState<_LinuxCloudAvailabilityDialog> createState() => + _LinuxCloudAvailabilityDialogState(); +} + +class _LinuxCloudAvailabilityDialogState + extends ConsumerState<_LinuxCloudAvailabilityDialog> { + bool _loading = true; + int _generation = 0; + Object? _error; + List _results = const []; + + @override + void initState() { + super.initState(); + unawaited(_load()); + } + + Future _load() async { + final generation = ++_generation; + setState(() { + _loading = true; + _error = null; + }); + try { + final client = ref.read( + calendarRemoteApiClientForAccountProvider(widget.draft.accountId), + ); + final interval = widget.draft.cloudAvailabilityInterval( + calendarTimeZone: widget.calendarTimeZone, + ); + if (client is! DetailedFreeBusyClient || interval == null) { + throw StateError('Availability is unavailable.'); + } + final recipients = widget.draft.attendees + .where((attendee) => !attendee.self && !attendee.organizer) + .map((attendee) => attendee.email.trim()) + .where((email) => email.isNotEmpty) + .toSet() + .toList(); + final fetched = await (client as DetailedFreeBusyClient).freeBusyDetails( + calendarIds: recipients, + rangeStart: interval.start.toUtc(), + rangeEnd: interval.end.toUtc(), + ); + if (mounted && generation == _generation) _results = fetched; + } on Object catch (error) { + if (mounted && generation == _generation) _error = error; + } finally { + if (mounted && generation == _generation) { + setState(() => _loading = false); + } + } + } + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + final date = DateFormat.yMMMd(Localizations.localeOf(context).toString()); + String interval(BusySlotDto slot) => + '${formatClockDateTime(context, slot.start.toLocal(), date.format(slot.start.toLocal()))} – ' + '${formatClockDateTime(context, slot.end.toLocal(), date.format(slot.end.toLocal()))}'; + return BusyMaxDialogShell( + key: const ValueKey('linux-cloud-availability-dialog'), + title: l10n.nextcloudGuestAvailability, + maxWidth: 600, + actions: [ + BusyMaxPushButton.standard( + onPressed: _loading ? null : _load, + child: Text(l10n.refresh), + ), + BusyMaxPushButton.suggested( + onPressed: () => Navigator.pop(context), + child: Text(l10n.close), + ), + ], + children: [ + if (_loading) const Center(child: YaruCircularProgressIndicator()), + if (_error != null) Text(redactForLog(_error)), + if (!_loading && _error == null) + for (final result in _results) + Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SelectableText(result.calendarId), + Text( + !result.succeeded + ? l10n.nextcloudAvailabilityUnknown + : result.busySlots.isEmpty + ? l10n.nextcloudAvailabilityFree + : result.busySlots.map(interval).join('\n'), + ), + if (result.errors.isNotEmpty) Text(result.errors.join(' · ')), + ], + ), + ), + ], + ); + } +} diff --git a/lib/src/features/notifications/notification_schedule_service.dart b/lib/src/features/notifications/notification_schedule_service.dart index d36bc8da..2cfd2418 100644 --- a/lib/src/features/notifications/notification_schedule_service.dart +++ b/lib/src/features/notifications/notification_schedule_service.dart @@ -1,4 +1,5 @@ import 'dart:convert'; +import '../../microsoft_calendar/microsoft_shared_calendar_address.dart'; import 'package:drift/drift.dart'; import 'package:uuid/uuid.dart'; @@ -65,6 +66,17 @@ class NotificationScheduleService { if (source == null) { continue; } + final sourceMetadata = _decodeJson(source.rawJson ?? '{}'); + if (sourceMetadata is Map && + MicrosoftSharedPrimaryCalendarAddress.parse( + source.providerCalendarId, + ) != + null && + (sourceMetadata['_busymaxOwnerAccessUnavailable'] == true || + (event.visibility?.toLowerCase() == 'private' && + sourceMetadata['canViewPrivateItems'] != true))) { + continue; + } final startUtc = calendarEventStartAsLocal(event)?.toUtc(); if (startUtc == null) { continue; @@ -605,6 +617,13 @@ List<_EventReminder> _davEventReminders( required DateTime startUtc, required DateTime? endUtc, }) { + if (reminderData['davEditableRows'] case final List rows) { + return _minuteReminders([ + for (final row in rows) + if (row is Map && row['minutes'] is int && (row['minutes'] as int) >= 0) + row['minutes'] as int, + ], startUtc: startUtc); + } final rawAlarms = reminderData['alarms']; if (rawAlarms is List) { final result = <_EventReminder>[]; diff --git a/lib/src/features/schedule/presentation/attachment_download.dart b/lib/src/features/schedule/presentation/attachment_download.dart new file mode 100644 index 00000000..a026cb7a --- /dev/null +++ b/lib/src/features/schedule/presentation/attachment_download.dart @@ -0,0 +1,38 @@ +import 'dart:io'; + +import 'package:file_selector/file_selector.dart'; + +/// Reject provider-controlled path components before presenting a suggested +/// destination. The user still explicitly chooses the final save location. +String? safeAttachmentFileName(String name) { + final value = name.trim(); + if (value.isEmpty || + value == '.' || + value == '..' || + value.endsWith('.') || + value.length > 255 || + value.contains('/') || + value.contains('\\') || + RegExp(r'[<>:"|?*]').hasMatch(value) || + RegExp( + r'^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])(?:\.|$)', + caseSensitive: false, + ).hasMatch(value) || + value.codeUnits.any((unit) => unit < 32 || unit == 127)) { + return null; + } + return value; +} + +Future saveAttachmentOnDesktop({ + required String name, + required List bytes, +}) async { + final safeName = safeAttachmentFileName(name); + if (safeName == null) throw const FormatException('Invalid attachment name.'); + final location = await getSaveLocation(suggestedName: safeName); + if (location == null) return null; + final file = File(location.path); + await file.writeAsBytes(bytes, flush: true); + return file.path; +} diff --git a/lib/src/features/schedule/presentation/attachment_upload_coordinator.dart b/lib/src/features/schedule/presentation/attachment_upload_coordinator.dart new file mode 100644 index 00000000..7f209168 --- /dev/null +++ b/lib/src/features/schedule/presentation/attachment_upload_coordinator.dart @@ -0,0 +1,510 @@ +import 'dart:async'; +import 'dart:math'; + +import 'package:flutter/foundation.dart'; + +import '../../../calendar_providers/attachment_upload_session.dart'; +import '../../../core/http/request_dispatch_exception.dart'; +import '../../../microsoft_calendar/microsoft_calendar_errors.dart'; +import '../../../microsoft_calendar/microsoft_calendar_api_client.dart'; +import '../../../microsoft_todo/api/microsoft_todo_api_client.dart'; +import '../../../microsoft_todo/api/microsoft_todo_api_error.dart'; + +typedef AttachmentUploadKey = ({ + String accountId, + String kind, + String containerId, + String itemId, +}); + +typedef AttachmentUploadRemoteItem = ({String id, String name, int? size}); + +enum AttachmentUploadStatus { ready, submitting, unresolved, committed } + +class AttachmentUploadUnresolvedException implements Exception { + const AttachmentUploadUnresolvedException(); + + @override + String toString() => + 'Attachment upload outcome is unresolved. Refresh attachments before retrying.'; +} + +class AttachmentUploadRateLimitedException implements Exception { + const AttachmentUploadRateLimitedException(this.retryAfter); + + final Duration retryAfter; + + @override + String toString() => 'Attachment upload is rate-limited. Retry later.'; +} + +final class _UploadCooldown { + _UploadCooldown(this.until, this.attempts, this.timer); + + final DateTime until; + final int attempts; + final Timer timer; +} + +final class _UnresolvedUpload { + _UnresolvedUpload({ + required this.name, + required this.size, + required this.beforeIds, + required this.contentType, + required this.bytes, + }); + + final String name; + final int size; + final Set beforeIds; + final String contentType; + final List bytes; + MicrosoftAttachmentUploadSession? session; + bool reviewed = false; +} + +final class _ConfirmedUpload { + const _ConfirmedUpload(this.id); + + final String id; +} + +/// Item-scoped operation state survives closing and reopening a detail view. +/// A missing list item immediately after a lost response is not proof that the +/// upload failed: Graph attachment collections may lag the write endpoint. +final class AttachmentUploadCoordinator extends ChangeNotifier { + AttachmentUploadCoordinator({DateTime Function()? now}) + : _now = now ?? DateTime.now; + + final DateTime Function() _now; + bool _disposed = false; + final _statuses = {}; + final _unresolved = {}; + final _confirmedAwaitingList = {}; + final _cooldowns = {}; + + @override + void dispose() { + _disposed = true; + for (final cooldown in _cooldowns.values) { + cooldown.timer.cancel(); + } + super.dispose(); + } + + AttachmentUploadStatus status(AttachmentUploadKey key) => + _statuses[key] ?? AttachmentUploadStatus.ready; + + bool canSubmit(AttachmentUploadKey key) => + status(key) != AttachmentUploadStatus.submitting && + _unresolved[key] == null && + retryAfter(key) == null; + + Duration? retryAfter(AttachmentUploadKey key) { + final cooldown = _cooldowns[key]; + if (cooldown == null) return null; + final remaining = cooldown.until.difference(_now()); + return remaining > Duration.zero ? remaining : null; + } + + bool needsReconciliation(AttachmentUploadKey key) => + (_unresolved[key] != null || _confirmedAwaitingList.containsKey(key)) && + status(key) != AttachmentUploadStatus.submitting; + + bool hasResumableSession(AttachmentUploadKey key) => + _unresolved[key]?.session != null; + + String? confirmedId(AttachmentUploadKey key) => + _confirmedAwaitingList[key]?.id; + + bool canResolveManually(AttachmentUploadKey key) => + _unresolved[key]?.reviewed == true && + status(key) != AttachmentUploadStatus.submitting; + + static AttachmentUploadKey eventKey( + String accountId, + String calendarId, + String eventId, + ) => ( + accountId: accountId, + kind: 'event', + containerId: calendarId, + itemId: eventId, + ); + + static AttachmentUploadKey taskKey( + String accountId, + String taskListId, + String taskId, + ) => ( + accountId: accountId, + kind: 'task', + containerId: taskListId, + itemId: taskId, + ); + + Future uploadEvent({ + required MicrosoftCalendarApiClient client, + required String accountId, + required String calendarId, + required String eventId, + required String name, + required String contentType, + required List bytes, + }) => upload( + key: eventKey(accountId, calendarId, eventId), + name: name, + size: bytes.length, + contentType: contentType, + bytes: bytes, + list: () async => [ + for (final item in await client.listEventAttachments( + calendarId: calendarId, + eventId: eventId, + )) + (id: item.id, name: item.name, size: item.size), + ], + submit: (onSession) => client.uploadEventFileAttachment( + calendarId: calendarId, + eventId: eventId, + name: name, + contentType: contentType, + bytes: bytes, + onSession: onSession, + ), + ); + + Future reconcileEvent({ + required MicrosoftCalendarApiClient client, + required String accountId, + required String calendarId, + required String eventId, + }) => _reconcile( + key: eventKey(accountId, calendarId, eventId), + list: () async => [ + for (final item in await client.listEventAttachments( + calendarId: calendarId, + eventId: eventId, + )) + (id: item.id, name: item.name, size: item.size), + ], + resume: (attempt) => client.uploadEventFileAttachment( + calendarId: calendarId, + eventId: eventId, + name: attempt.name, + contentType: attempt.contentType, + bytes: attempt.bytes, + resumeSession: attempt.session, + ), + ); + + Future cancelEvent({ + required MicrosoftCalendarApiClient client, + required String accountId, + required String calendarId, + required String eventId, + }) => cancelSession( + key: eventKey(accountId, calendarId, eventId), + cancel: client.cancelEventAttachmentUpload, + ); + + Future> _taskItems( + MicrosoftTodoAttachmentsApiClient client, + String taskListId, + String taskId, + ) async { + final items = []; + final seen = {}; + String? next; + do { + final page = await client.listTaskAttachmentsPage( + taskListId: taskListId, + taskId: taskId, + nextLink: next, + ); + items.addAll([ + for (final item in page.attachments) + (id: item.id, name: item.name, size: item.size), + ]); + next = page.nextLink; + if (next != null && !seen.add(next)) { + throw const FormatException('Attachment pagination loop.'); + } + } while (next != null); + return items; + } + + Future uploadTask({ + required MicrosoftTodoAttachmentsApiClient client, + required String accountId, + required String taskListId, + required String taskId, + required String name, + required String contentType, + required List bytes, + }) => upload( + key: taskKey(accountId, taskListId, taskId), + name: name, + size: bytes.length, + contentType: contentType, + bytes: bytes, + list: () => _taskItems(client, taskListId, taskId), + submit: (onSession) => client.uploadTaskFileAttachment( + taskListId: taskListId, + taskId: taskId, + name: name, + contentType: contentType, + bytes: bytes, + onSession: onSession, + ), + ); + + Future reconcileTask({ + required MicrosoftTodoAttachmentsApiClient client, + required String accountId, + required String taskListId, + required String taskId, + }) => _reconcile( + key: taskKey(accountId, taskListId, taskId), + list: () => _taskItems(client, taskListId, taskId), + resume: (attempt) => client.uploadTaskFileAttachment( + taskListId: taskListId, + taskId: taskId, + name: attempt.name, + contentType: attempt.contentType, + bytes: attempt.bytes, + resumeSession: attempt.session, + ), + ); + + Future cancelTask({ + required MicrosoftTodoAttachmentsApiClient client, + required String accountId, + required String taskListId, + required String taskId, + }) => cancelSession( + key: taskKey(accountId, taskListId, taskId), + cancel: client.cancelTaskAttachmentUpload, + ); + + void _set(AttachmentUploadKey key, AttachmentUploadStatus value) { + _statuses[key] = value; + notifyListeners(); + } + + Future upload({ + required AttachmentUploadKey key, + required String name, + required int size, + required String contentType, + required List bytes, + required Future> Function() list, + required Future Function( + void Function(MicrosoftAttachmentUploadSession), + ) + submit, + }) async { + if (retryAfter(key) case final remaining?) { + throw AttachmentUploadRateLimitedException(remaining); + } + if (!canSubmit(key)) throw const AttachmentUploadUnresolvedException(); + // A returned attachment ID completed the prior write. Its pending list + // refresh is presentation-only and must not become state for this attempt. + _confirmedAwaitingList.remove(key); + _set(key, AttachmentUploadStatus.submitting); + late final Set beforeIds; + try { + beforeIds = (await list()) + .map((item) => item.id) + .where((id) => id.isNotEmpty) + .toSet(); + } on Object { + _set(key, AttachmentUploadStatus.ready); + rethrow; + } + final attempt = _UnresolvedUpload( + name: name, + size: size, + beforeIds: beforeIds, + contentType: contentType, + bytes: bytes, + ); + try { + final id = await submit((session) { + attempt.session = session; + _unresolved[key] = attempt; + }); + final confirmed = _confirm(key, id); + _clearCooldown(key); + unawaited(_refreshConfirmed(key, confirmed, list)); + return; + } on Object catch (error) { + if (attempt.session == null && _rateLimitDelay(error) != null) { + _unresolved.remove(key); + _startCooldown(key, _rateLimitDelay(error)); + _set(key, AttachmentUploadStatus.ready); + rethrow; + } + if (attempt.session == null && _confirmedNotCommitted(error)) { + _unresolved.remove(key); + _set(key, AttachmentUploadStatus.ready); + rethrow; + } + _unresolved[key] = attempt; + _set(key, AttachmentUploadStatus.unresolved); + throw const AttachmentUploadUnresolvedException(); + } + } + + Duration? _rateLimitDelay(Object error) => switch (error) { + MicrosoftCalendarApiError e when e.isRateLimited => + e.retryAfter ?? Duration.zero, + MicrosoftTodoApiError e when e.statusCode == 429 => + e.retryAfter ?? Duration.zero, + _ => null, + }; + + void _startCooldown(AttachmentUploadKey key, Duration? serverDelay) { + final prior = _cooldowns[key]; + final attempts = (prior?.attempts ?? 0) + 1; + prior?.timer.cancel(); + final baseMs = min(300000, 2000 * (1 << min(attempts - 1, 7))); + final jitterMs = (baseMs * Random().nextDouble() / 4).round(); + final delay = serverDelay == null || serverDelay <= Duration.zero + ? Duration(milliseconds: baseMs + jitterMs) + : serverDelay; + final timer = Timer(delay, () { + if (!_disposed) notifyListeners(); + }); + _cooldowns[key] = _UploadCooldown(_now().add(delay), attempts, timer); + } + + void _clearCooldown(AttachmentUploadKey key) { + _cooldowns.remove(key)?.timer.cancel(); + } + + _ConfirmedUpload _confirm(AttachmentUploadKey key, String id) { + if (id.isEmpty) throw const MicrosoftAttachmentUploadUncertain(); + _unresolved.remove(key); + final confirmed = _ConfirmedUpload(id); + _confirmedAwaitingList[key] = confirmed; + _set(key, AttachmentUploadStatus.committed); + return confirmed; + } + + Future _refreshConfirmed( + AttachmentUploadKey key, + _ConfirmedUpload confirmed, + Future> Function() list, + ) async { + try { + await list(); + // Any successful list completes the presentation refresh. Identity is + // checked so a late refresh for A cannot clear B's confirmation. + if (identical(_confirmedAwaitingList[key], confirmed)) { + _confirmedAwaitingList.remove(key); + if (!_disposed) notifyListeners(); + } + } on Object { + // The attachment ID proves the write; a failed list is only a pending + // presentation refresh, never a reason to resubmit the payload. + } + } + + Future _reconcile({ + required AttachmentUploadKey key, + required Future> Function() list, + required Future Function(_UnresolvedUpload) resume, + }) async { + final attempt = _unresolved[key]; + if (attempt == null) { + final confirmed = _confirmedAwaitingList[key]; + if (confirmed != null) { + await _refreshConfirmed(key, confirmed, list); + } + return status(key); + } + if (attempt.session != null) { + _set(key, AttachmentUploadStatus.submitting); + try { + final id = await resume(attempt); + final confirmed = _confirm(key, id); + unawaited(_refreshConfirmed(key, confirmed, list)); + return status(key); + } on Object { + attempt.reviewed = true; + _set(key, AttachmentUploadStatus.unresolved); + throw const AttachmentUploadUnresolvedException(); + } + } + // A same-name, same-size attachment from another client is not evidence + // that this attempt committed. Listing is still useful for the user to + // inspect, but only the response ID can automatically confirm it. + await list(); + attempt.reviewed = true; + return status(key); + } + + /// Clears presentation-only confirmation for the attachment just removed. + /// The ID guard prevents a delayed deletion for A from affecting B. + void attachmentRemoved(AttachmentUploadKey key, String attachmentId) { + if (_confirmedAwaitingList[key]?.id != attachmentId) return; + _confirmedAwaitingList.remove(key); + notifyListeners(); + } + + Future cancelSession({ + required AttachmentUploadKey key, + required Future Function(MicrosoftAttachmentUploadSession) cancel, + }) async { + final attempt = _unresolved[key]; + final session = attempt?.session; + if (session == null || status(key) == AttachmentUploadStatus.submitting) { + throw const AttachmentUploadUnresolvedException(); + } + _set(key, AttachmentUploadStatus.submitting); + try { + await cancel(session); + _unresolved.remove(key); + _set(key, AttachmentUploadStatus.ready); + } on Object { + _set(key, AttachmentUploadStatus.unresolved); + throw const AttachmentUploadUnresolvedException(); + } + } + + /// The user has independently verified the outcome in the provider. This + /// must only be invoked behind an explicit confirmation in native UI. + void resolveUncertainManually( + AttachmentUploadKey key, { + required bool exists, + }) { + if (_unresolved[key]?.reviewed != true || + status(key) == AttachmentUploadStatus.submitting) { + throw const AttachmentUploadUnresolvedException(); + } + _unresolved.remove(key); + _set( + key, + exists ? AttachmentUploadStatus.committed : AttachmentUploadStatus.ready, + ); + } + + bool _confirmedNotCommitted(Object error) { + if (error is RequestNotDispatchedException || error is ArgumentError) { + return true; + } + final code = switch (error) { + MicrosoftCalendarApiError e => e.statusCode, + MicrosoftTodoApiError e => e.statusCode, + _ => null, + }; + return code != null && + code >= 400 && + code < 500 && + code != 408 && + code != 409 && + code != 429; + } +} diff --git a/lib/src/features/schedule/presentation/cloud_calendar_series_export.dart b/lib/src/features/schedule/presentation/cloud_calendar_series_export.dart new file mode 100644 index 00000000..fe38e452 --- /dev/null +++ b/lib/src/features/schedule/presentation/cloud_calendar_series_export.dart @@ -0,0 +1,1003 @@ +import '../../../calendar_providers/calendar_sync_dto.dart'; +import '../../../core/time/provider_date_time.dart'; +import '../../../core/time/windows_time_zone_ids.dart'; +import '../../../google_calendar/google_calendar_api_client.dart'; +import '../../../microsoft_calendar/microsoft_calendar_api_client.dart'; +import '../../../providers/busy_provider.dart'; +import '../../recurrence/domain/event_recurrence_codec.dart'; +import 'package:timezone/timezone.dart' as tz; + +import 'tz_continuations_2025c.dart'; + +/// Only offer a series backup when the authoritative resource can be read. +/// WebCal projections and unsynced cloud creates can still export an +/// occurrence, but must not be presented as complete series exports. +bool canExportAuthoritativeEventSeries({ + required BusyProvider provider, + required String? providerEventId, + required String? davCollectionId, +}) => switch (provider) { + BusyProvider.google || BusyProvider.microsoft => + providerEventId != null && providerEventId.isNotEmpty, + BusyProvider.nextcloud || BusyProvider.appleICloud => + davCollectionId != null && davCollectionId.isNotEmpty, + _ => false, +}; + +/// Reads the provider's complete master/exception set before serializing it. +/// A finite schedule cache is never treated as an authoritative series backup. +Future exportGoogleEventSeries({ + required GoogleCalendarApiClient client, + required String calendarId, + required String eventId, + required DateTime nowUtc, +}) async { + final selected = await client.getEvent( + calendarId: calendarId, + eventId: eventId, + ); + final masterId = + selected.providerRecurringEventId ?? selected.providerEventId; + final master = masterId == selected.providerEventId + ? selected + : await client.getEvent(calendarId: calendarId, eventId: masterId); + final uid = _uid(master); + final members = await client.eventsWithICalUid( + calendarId: calendarId, + iCalUid: uid, + includeCancelled: true, + ); + final authoritativeMaster = members + .where( + (event) => + event.providerEventId == masterId && + event.providerRecurringEventId == null, + ) + .toList(); + if (authoritativeMaster.length != 1) { + throw const FormatException( + 'The complete Google series master is unavailable.', + ); + } + final exceptions = members + .where((event) => event.providerRecurringEventId == masterId) + .toList(); + List>? defaultReminders; + if ([authoritativeMaster.single, ...exceptions].any( + (event) => + !event.isCancelled && + event.remindersJson is Map && + (event.remindersJson as Map)['useDefault'] == true, + )) { + final calendars = await client.listCalendars(); + final matching = calendars + .where((source) => source.providerCalendarId == calendarId) + .toList(); + if (matching.length != 1) { + throw const FormatException( + 'Google calendar default reminders are unavailable.', + ); + } + final rawValue = matching.single.rawJson['defaultReminders']; + if (rawValue != null && rawValue is! List) { + throw const FormatException('Malformed Google default reminders.'); + } + final raw = rawValue as List? ?? const []; + if (raw.any((value) => value is! Map)) { + throw const FormatException('Malformed Google default reminders.'); + } + defaultReminders = [ + for (final value in raw) Map.from(value as Map), + ]; + } + return cloudSeriesToICalendar( + master: authoritativeMaster.single, + exceptions: exceptions, + googleDefaultReminders: defaultReminders, + nowUtc: nowUtc, + ); +} + +Future exportMicrosoftEventSeries({ + required MicrosoftCalendarApiClient client, + required String calendarId, + required String eventId, + required DateTime nowUtc, +}) async { + final selected = await client.getEvent( + calendarId: calendarId, + eventId: eventId, + ); + final masterId = + selected.providerRecurringEventId ?? selected.providerEventId; + final master = masterId == selected.providerEventId + ? selected + : await client.getEvent(calendarId: calendarId, eventId: masterId); + if (master.recurrenceJson == null) { + throw const FormatException('The Microsoft series master is unavailable.'); + } + final snapshot = await client.getSeriesExceptionSnapshot( + calendarId: calendarId, + recurringEventId: masterId, + ); + final exceptions = []; + for (final partial in snapshot.exceptions) { + if (partial.providerEventId.isEmpty) { + throw const FormatException( + 'A Microsoft series exception has no identity.', + ); + } + exceptions.add( + await client.getEvent( + calendarId: calendarId, + eventId: partial.providerEventId, + ), + ); + } + return cloudSeriesToICalendar( + master: master, + exceptions: exceptions, + cancelledOccurrenceIds: snapshot.cancelledIds, + nowUtc: nowUtc, + ); +} + +String cloudSeriesToICalendar({ + required CalendarEventDto master, + required List exceptions, + Set cancelledOccurrenceIds = const {}, + List>? googleDefaultReminders, + required DateTime nowUtc, +}) { + final uid = _uid(master); + if (master.providerRecurringEventId != null) { + throw const FormatException( + 'A recurrence instance cannot be a series master.', + ); + } + final microsoftZone = master.provider == BusyProvider.microsoft + ? _microsoftSeriesZone(master) + : null; + final eventLines = [ + ..._eventLines( + master, + uid: uid, + nowUtc: nowUtc, + googleDefaultReminders: googleDefaultReminders, + microsoftSeriesZone: microsoftZone, + ), + ]; + final seenOriginals = {}; + for (final exception in exceptions) { + if (exception.providerRecurringEventId != master.providerEventId) { + throw const FormatException( + 'An exception belongs to a different series.', + ); + } + final original = exception.providerOriginalStartKey; + if (original == null || original.isEmpty || !seenOriginals.add(original)) { + throw const FormatException( + 'A series exception has an ambiguous original start.', + ); + } + eventLines.addAll( + _eventLines( + exception, + uid: uid, + nowUtc: nowUtc, + originalStart: original, + originalZone: master.startTimeZone, + microsoftSeriesZone: microsoftZone, + googleDefaultReminders: googleDefaultReminders, + ), + ); + } + for (final id in cancelledOccurrenceIds) { + final match = RegExp(r'\.(\d{4}-\d{2}-\d{2})$').firstMatch(id); + if (match == null) { + throw const FormatException( + 'A cancelled occurrence has no usable original date.', + ); + } + final date = match.group(1)!; + final masterStart = master.startDateTime; + final original = master.allDay + ? date + : '$date${_wallTimeSuffix(_seriesWallValue(masterStart, master.startTimeZone, microsoftZone))}'; + if (!seenOriginals.add(original)) continue; + eventLines.addAll([ + 'BEGIN:VEVENT', + 'UID:${_text(uid)}', + 'DTSTAMP:${_utc(nowUtc)}', + _dateLine( + 'RECURRENCE-ID', + original, + microsoftZone ?? master.startTimeZone, + master.allDay, + ), + 'STATUS:CANCELLED', + 'END:VEVENT', + ]); + } + final referencedZones = RegExp( + r';TZID=([^:]+):', + ).allMatches(eventLines.join('\n')).map((match) => match.group(1)!).toSet(); + final lines = [ + 'BEGIN:VCALENDAR', + 'VERSION:2.0', + 'PRODID:-//BusyMax//BusyMax//EN', + 'CALSCALE:GREGORIAN', + for (final zone in referencedZones) ..._vtimezoneLines(zone, master), + ...eventLines, + ]; + lines.add('END:VCALENDAR'); + return '${lines.join('\r\n')}\r\n'; +} + +List _eventLines( + CalendarEventDto event, { + required String uid, + required DateTime nowUtc, + String? originalStart, + String? originalZone, + String? microsoftSeriesZone, + List>? googleDefaultReminders, +}) { + final lines = [ + 'BEGIN:VEVENT', + 'UID:${_text(uid)}', + 'DTSTAMP:${_utc(nowUtc)}', + ]; + if (originalStart != null) { + lines.add( + _dateLine( + 'RECURRENCE-ID', + originalStart, + originalZone ?? event.startTimeZone, + event.allDay, + targetZone: microsoftSeriesZone, + ), + ); + } + if (event.isCancelled) { + lines.add('STATUS:CANCELLED'); + } else { + final start = event.allDay ? event.startDate : event.startDateTime; + final end = event.allDay ? event.endDate : event.endDateTime; + if (start == null || end == null) { + throw const FormatException('A series event is missing its interval.'); + } + lines.add( + _dateLine( + 'DTSTART', + start, + event.startTimeZone, + event.allDay, + targetZone: microsoftSeriesZone, + ), + ); + lines.add( + _dateLine( + 'DTEND', + end, + event.endTimeZone, + event.allDay, + targetZone: microsoftSeriesZone, + ), + ); + lines.add('SUMMARY:${_text(event.title)}'); + if (event.description?.isNotEmpty == true) { + lines.add('DESCRIPTION:${_text(event.description!)}'); + } + if (event.location?.isNotEmpty == true) { + lines.add('LOCATION:${_text(event.location!)}'); + } + if (event.categoriesJson is List) { + final categories = (event.categoriesJson as List) + .whereType() + .where((value) => value.trim().isNotEmpty) + .toList(); + if (categories.isNotEmpty) { + lines.add('CATEGORIES:${categories.map(_text).join(',')}'); + } + } + final classification = switch (event.visibility?.toLowerCase()) { + 'private' || 'personal' => 'PRIVATE', + 'confidential' => 'CONFIDENTIAL', + 'public' || 'normal' => 'PUBLIC', + _ => null, + }; + if (classification != null) lines.add('CLASS:$classification'); + final transparent = switch (event.transparencyOrShowAs?.toLowerCase()) { + 'transparent' || 'free' => true, + 'opaque' || 'busy' => false, + _ => null, + }; + if (transparent != null) { + lines.add('TRANSP:${transparent ? 'TRANSPARENT' : 'OPAQUE'}'); + } + if (_safeHttps(event.webLink)) { + lines.add('URL:${event.webLink}'); + } + if (event.provider == BusyProvider.google && + event.attachmentsJson is List) { + for (final attachment in event.attachmentsJson as List) { + if (attachment is! Map) continue; + final url = attachment['fileUrl']?.toString(); + if (_safeHttps(url)) lines.add('ATTACH:$url'); + } + } + if (event.recurrenceJson != null) { + if (event.provider == BusyProvider.google) { + final recurrence = event.recurrenceJson; + if (recurrence is! List || recurrence.any((line) => line is! String)) { + throw const FormatException('Unsupported Google recurrence data.'); + } + for (final line in recurrence) { + final value = line as String; + if (!RegExp( + r'^(RRULE|EXRULE|RDATE|EXDATE)(;[^:\r\n]*)?:[^\r\n]+$', + caseSensitive: false, + ).hasMatch(value)) { + throw const FormatException('Unsupported Google recurrence line.'); + } + lines.add(value); + } + } else if (event.provider == BusyProvider.microsoft) { + final startCivil = providerDateTimeAsWallTime( + _seriesWallValue(start, event.startTimeZone, microsoftSeriesZone), + microsoftSeriesZone ?? event.startTimeZone, + ); + if (startCivil == null) { + throw const FormatException('Invalid series start.'); + } + final rule = EventRecurrenceCodec.decode( + event.provider, + event.recurrenceJson, + baseDate: startCivil, + ); + if (!rule.isSupported || !rule.repeats) { + throw const FormatException( + 'Microsoft recurrence cannot be exported safely.', + ); + } + final endDate = rule.untilRaw; + final exportRule = + !event.allDay && + endDate != null && + RegExp(r'^\d{8}$').hasMatch(endDate) + ? rule.copyWith( + untilRaw: _microsoftTimedUntil( + endDate, + startCivil, + microsoftSeriesZone ?? event.startTimeZone, + ), + ) + : rule; + lines.add('RRULE:${exportRule.toRrule()}'); + } + } + lines.addAll(_attendeeLines(event)); + lines.addAll(_reminderLines(event, googleDefaultReminders)); + } + lines.add('END:VEVENT'); + return lines; +} + +List _attendeeLines(CalendarEventDto event) { + final result = []; + final organizer = event.organizerJson; + if (organizer is Map) { + final address = + organizer['email'] ?? + (organizer['emailAddress'] is Map + ? (organizer['emailAddress'] as Map)['address'] + : null); + if (address is String && _safeEmail(address)) { + result.add('ORGANIZER:mailto:$address'); + } + } + final attendees = event.attendeesJson; + if (attendees is List) { + for (final value in attendees) { + if (value is! Map) continue; + final address = + value['email'] ?? + (value['emailAddress'] is Map + ? (value['emailAddress'] as Map)['address'] + : null); + if (address is! String || !_safeEmail(address)) continue; + final optional = value['optional'] == true || value['type'] == 'optional'; + final response = + value['responseStatus']?.toString() ?? + (value['status'] is Map + ? (value['status'] as Map)['response']?.toString() + : null); + final partstat = switch (response) { + 'accepted' => 'ACCEPTED', + 'declined' => 'DECLINED', + 'tentative' || 'tentativelyAccepted' => 'TENTATIVE', + 'needsAction' || 'notResponded' => 'NEEDS-ACTION', + _ => null, + }; + final name = + value['displayName']?.toString() ?? + (value['emailAddress'] is Map + ? (value['emailAddress'] as Map)['name']?.toString() + : null); + result.add( + 'ATTENDEE${optional ? ';ROLE=OPT-PARTICIPANT' : ''}' + '${partstat == null ? '' : ';PARTSTAT=$partstat'}' + '${name == null || name.isEmpty ? '' : ';CN=${_parameter(name)}'}' + ':mailto:$address', + ); + } + } + return result; +} + +List _reminderLines( + CalendarEventDto event, + List>? googleDefaultReminders, +) { + final data = event.remindersJson; + final minutes = []; + if (event.provider == BusyProvider.google && data is Map) { + final overrides = data['useDefault'] == true + ? googleDefaultReminders + : data['overrides']; + if (data['useDefault'] == true && overrides == null) { + throw const FormatException('Google default reminders are unavailable.'); + } + if (overrides is List) { + for (final item in overrides) { + if (item is! Map || + item['method'] != 'popup' || + item['minutes'] is! int) { + throw const FormatException('Unsupported Google reminder format.'); + } + minutes.add(item['minutes'] as int); + } + } else if (overrides != null) { + throw const FormatException('Malformed Google reminder collection.'); + } + } else if (event.provider == BusyProvider.microsoft && + data is Map && + data['isReminderOn'] == true && + data['reminderMinutesBeforeStart'] is int) { + minutes.add(data['reminderMinutesBeforeStart'] as int); + } + return [ + for (final value in minutes) + if (value >= 0) ...[ + 'BEGIN:VALARM', + 'ACTION:DISPLAY', + 'TRIGGER:-PT${value}M', + 'DESCRIPTION:${_text(event.title)}', + 'END:VALARM', + ], + ]; +} + +String _uid(CalendarEventDto event) { + final value = + event.rawJson[event.provider == BusyProvider.google + ? 'iCalUID' + : 'uid'] ?? + event.rawJson['iCalUId']; + if (value is! String || value.trim().isEmpty) { + throw const FormatException( + 'The provider did not return an iCalendar UID.', + ); + } + return value.trim(); +} + +String? _microsoftSeriesZone(CalendarEventDto master) { + final recurrence = master.recurrenceJson; + final range = recurrence is Map ? recurrence['range'] : null; + final declared = range is Map + ? range['recurrenceTimeZone']?.toString() + : null; + final zone = declared?.trim().isNotEmpty == true + ? declared!.trim() + : master.rawJson['originalStartTimeZone']?.toString() ?? + master.startTimeZone; + if (zone == null || zone.trim().isEmpty) { + throw const FormatException( + 'The Microsoft recurrence timezone is unavailable.', + ); + } + final normalized = windowsToIanaTimeZones[zone] ?? zone; + providerInstantInTimeZone(DateTime.utc(2026), normalized); + return normalized; +} + +String _seriesWallValue(String? value, String? sourceZone, String? targetZone) { + if (value == null) throw const FormatException('Series time is missing.'); + if (targetZone == null || !value.contains('T')) return value; + final instant = _exportInstant(value, sourceZone); + if (instant == null) throw const FormatException('Invalid series time.'); + return providerWallTimeIso8601String( + _exportWallFromInstant(instant, targetZone), + ); +} + +String _microsoftTimedUntil( + String basicDate, + DateTime startCivil, + String? zone, +) { + if (zone == null || zone.trim().isEmpty) { + throw const FormatException('A timed recurrence needs its timezone.'); + } + final year = int.parse(basicDate.substring(0, 4)); + final month = int.parse(basicDate.substring(4, 6)); + final day = int.parse(basicDate.substring(6, 8)); + final wall = DateTime.utc( + year, + month, + day, + startCivil.hour, + startCivil.minute, + startCivil.second, + ); + if (wall.year != year || wall.month != month || wall.day != day) { + throw const FormatException('Invalid Microsoft recurrence end date.'); + } + return _utc(_exportInstantFromWall(wall, zone)); +} + +DateTime? _exportInstant(String? value, String? zone) { + if (value == null || + zone == null || + !value.contains('T') || + RegExp(r'(?:[zZ]|[+-]\d{2}(?::?\d{2})?)$').hasMatch(value)) { + return providerDateTimeAsUtcInstant(value, zone); + } + final wall = providerDateTimeAsWallTime(value, zone); + return wall == null ? null : _exportInstantFromWall(wall, zone); +} + +DateTime _exportWallFromInstant(DateTime instant, String zone) { + zone = windowsToIanaTimeZones[zone] ?? zone; + providerInstantInTimeZone(DateTime.utc(2026), zone); + final location = tz.getLocation(zone); + final last = location.transitionAt.lastOrNull; + if (last == null || instant.millisecondsSinceEpoch <= last) { + return providerInstantInTimeZone(instant, zone); + } + final offset = _PosixContinuation.parse(zone).zoneAt(instant).offset; + return DateTime.fromMillisecondsSinceEpoch( + instant.millisecondsSinceEpoch + offset.inMilliseconds, + isUtc: true, + ); +} + +DateTime _exportInstantFromWall(DateTime wall, String zone) { + zone = windowsToIanaTimeZones[zone] ?? zone; + providerInstantInTimeZone(DateTime.utc(2026), zone); + final location = tz.getLocation(zone); + final last = location.transitionAt.lastOrNull; + if (last == null || + wall.year <= + DateTime.fromMillisecondsSinceEpoch(last, isUtc: true).year) { + return providerWallTimeToInstant(wall, zone); + } + final continuation = _PosixContinuation.parse(zone); + final civil = DateTime.utc( + wall.year, + wall.month, + wall.day, + wall.hour, + wall.minute, + wall.second, + wall.millisecond, + wall.microsecond, + ); + final possible = + [ + continuation.standard, + if (continuation.daylight case final daylight?) daylight, + ].map((candidate) => civil.subtract(candidate.offset)).where((instant) { + final resolved = _exportWallFromInstant(instant, zone); + return resolved.year == wall.year && + resolved.month == wall.month && + resolved.day == wall.day && + resolved.hour == wall.hour && + resolved.minute == wall.minute && + resolved.second == wall.second; + }).toList() + ..sort(); + if (possible.isEmpty) { + throw FormatException( + 'Timezone $zone cannot resolve the recurrence wall time.', + ); + } + return possible.first; +} + +String _offset(Duration value) { + final minutes = value.inMinutes; + final absolute = minutes.abs(); + return '${minutes < 0 ? '-' : '+'}${(absolute ~/ 60).toString().padLeft(2, '0')}' + '${(absolute % 60).toString().padLeft(2, '0')}'; +} + +List _vtimezoneLines(String zone, CalendarEventDto master) { + providerInstantInTimeZone(DateTime.utc(2026), zone); + final location = tz.getLocation(zone); + final start = master.allDay ? master.startDate : master.startDateTime; + final firstYear = (DateTime.tryParse(start ?? '')?.year ?? 2026) - 1; + final firstInstant = DateTime.utc(firstYear, 1, 1); + final initial = + location.transitionAt.isNotEmpty && + firstInstant.millisecondsSinceEpoch > location.transitionAt.last + ? _PosixContinuation.parse(zone).zoneAt(firstInstant) + : location.timeZone(firstInstant.millisecondsSinceEpoch); + final lines = ['BEGIN:VTIMEZONE', 'TZID:$zone']; + void observance( + tz.TimeZone from, + tz.TimeZone to, + DateTime wall, { + String? rule, + List extraDates = const [], + }) { + lines.addAll([ + 'BEGIN:${to.isDst ? 'DAYLIGHT' : 'STANDARD'}', + 'DTSTART:${_wall(wall)}', + 'TZOFFSETFROM:${_offset(from.offset)}', + 'TZOFFSETTO:${_offset(to.offset)}', + 'TZNAME:${_text(to.abbreviation)}', + if (rule != null) 'RRULE:$rule', + for (var i = 0; i < extraDates.length; i += 3) + 'RDATE:${extraDates.skip(i).take(3).map(_wall).join(',')}', + 'END:${to.isDst ? 'DAYLIGHT' : 'STANDARD'}', + ]); + } + + observance(initial, initial, firstInstant.add(initial.offset)); + DateTime? lastTransition; + for (var i = 0; i < location.transitionAt.length; i++) { + final at = DateTime.fromMillisecondsSinceEpoch( + location.transitionAt[i], + isUtc: true, + ); + if (at.year < firstYear || at.year > 9998) continue; + final from = location.timeZone(at.millisecondsSinceEpoch - 1); + final to = location.zones[location.transitionZone[i]]; + lastTransition = at; + if (from.offset == to.offset) continue; + final wall = at.add(from.offset); + observance(from, to, wall); + } + // The compiled timezone TZF stops at its last explicit transition. Its + // matching IANA TZif footer is the authoritative rule beyond that point; + // historical patterns cannot distinguish discontinued DST from truncation. + final continuation = _PosixContinuation.parse(zone); + final lastAt = lastTransition ?? DateTime.utc(firstYear - 1); + final current = location.timeZone(lastAt.millisecondsSinceEpoch + 1); + final next = continuation.transitionsAfter(lastAt); + if (next.isNotEmpty && current.offset != next.first.from.offset) { + throw FormatException('Timezone $zone continuation does not match tzdata.'); + } + for (final group in [ + next.where((entry) => entry.to.isDst).toList(), + next.where((entry) => !entry.to.isDst).toList(), + ]) { + if (group.isEmpty) continue; + final first = group.first; + final rule = first.rule.icalRule; + if (rule != null) { + observance(first.from, first.to, first.wall, rule: rule); + } else { + // POSIX permits transition clocks outside 00:00–23:59 and Julian + // day rules. Enumerating those exact dates through iCalendar's final + // representable year avoids fabricating an inexact yearly RRULE. + observance( + first.from, + first.to, + first.wall, + extraDates: [for (final entry in group.skip(1)) entry.wall], + ); + } + } + lines.add('END:VTIMEZONE'); + return lines; +} + +typedef _FutureTransition = ({ + DateTime at, + DateTime wall, + tz.TimeZone from, + tz.TimeZone to, + _PosixDateRule rule, +}); + +final class _PosixContinuation { + const _PosixContinuation(this.standard, this.daylight, this.start, this.end); + + factory _PosixContinuation.parse(String zone) { + final source = tzContinuations2025c[zone]; + if (source == null) { + throw FormatException('No IANA continuation for $zone.'); + } + final match = RegExp( + r'^(<[^>]+>|[A-Za-z]{3,})([+-]?\d+(?::\d{1,2}){0,2})' + r'(?:(<[^>]+>|[A-Za-z]{3,})([+-]?\d+(?::\d{1,2}){0,2})?)?' + r'(?:,([^,]+),([^,]+))?$', + ).firstMatch(source); + if (match == null) { + throw FormatException('Invalid IANA continuation for $zone.'); + } + String name(String raw) => + raw.startsWith('<') ? raw.substring(1, raw.length - 1) : raw; + final standardOffset = -_posixSeconds(match.group(2)!); + final standard = tz.TimeZone( + Duration(seconds: standardOffset), + isDst: false, + abbreviation: name(match.group(1)!), + ); + if (match.group(3) == null) { + return _PosixContinuation(standard, null, null, null); + } + if (match.group(5) == null || match.group(6) == null) { + throw FormatException('Incomplete IANA continuation for $zone.'); + } + final daylight = tz.TimeZone( + Duration( + seconds: match.group(4) == null + ? standardOffset + 3600 + : -_posixSeconds(match.group(4)!), + ), + isDst: true, + abbreviation: name(match.group(3)!), + ); + return _PosixContinuation( + standard, + daylight, + _PosixDateRule.parse(match.group(5)!), + _PosixDateRule.parse(match.group(6)!), + ); + } + + final tz.TimeZone standard; + final tz.TimeZone? daylight; + final _PosixDateRule? start; + final _PosixDateRule? end; + + /// TZif v3 specifies this sentinel pair as daylight saving in effect all + /// year: January 1 00:00 through December 31 24:00 plus the DST difference. + bool get allYearDaylight => + daylight != null && + start?.date == '0' && + start?.seconds == 0 && + end?.date == 'J365' && + end?.seconds == 86400 + (daylight!.offset - standard.offset).inSeconds; + + tz.TimeZone zoneAt(DateTime instant) { + if (daylight == null) return standard; + if (allYearDaylight) return daylight!; + final transitions = <_FutureTransition>[]; + for (var year = instant.year - 1; year <= instant.year + 1; year++) { + if (year < 1 || year > 9999) continue; + for (final pair in [ + (start!, standard, daylight!), + (end!, daylight!, standard), + ]) { + final (rule, from, to) = pair; + final wall = rule.wall(year); + transitions.add(( + at: wall.subtract(from.offset), + wall: wall, + from: from, + to: to, + rule: rule, + )); + } + } + transitions.sort((a, b) => a.at.compareTo(b.at)); + tz.TimeZone current = standard; + for (final transition in transitions) { + if (transition.at.isAfter(instant)) break; + current = transition.to; + } + return current; + } + + List<_FutureTransition> transitionsAfter(DateTime lastAt) { + if (daylight == null) return const []; + if (allYearDaylight) return const []; + final result = <_FutureTransition>[]; + for (var year = lastAt.year; year <= 9999; year++) { + for (final pair in [ + (start!, standard, daylight!), + (end!, daylight!, standard), + ]) { + final (rule, from, to) = pair; + final wall = rule.wall(year); + if (wall.year > 9999) continue; + final at = wall.subtract(from.offset); + if (!at.isAfter(lastAt)) continue; + result.add((at: at, wall: wall, from: from, to: to, rule: rule)); + } + } + result.sort((a, b) => a.at.compareTo(b.at)); + return result; + } +} + +int _posixSeconds(String source) { + final sign = source.startsWith('-') ? -1 : 1; + final parts = source + .replaceFirst(RegExp(r'^[+-]'), '') + .split(':') + .map(int.parse) + .toList(); + if (parts.length > 3 || parts.skip(1).any((value) => value > 59)) { + throw const FormatException('Invalid POSIX timezone offset.'); + } + return sign * + (parts[0] * 3600 + + (parts.length > 1 ? parts[1] * 60 : 0) + + (parts.length > 2 ? parts[2] : 0)); +} + +final class _PosixDateRule { + const _PosixDateRule(this.date, this.seconds); + + factory _PosixDateRule.parse(String value) { + final parts = value.split('/'); + if (parts.length > 2) { + throw const FormatException('Invalid POSIX transition.'); + } + return _PosixDateRule( + parts[0], + parts.length == 2 ? _posixSeconds(parts[1]) : 7200, + ); + } + + final String date; + final int seconds; + + DateTime wall(int year) { + final monthRule = RegExp(r'^M(\d{1,2})\.(\d)\.(\d)$').firstMatch(date); + DateTime day; + if (monthRule != null) { + final month = int.parse(monthRule.group(1)!); + final week = int.parse(monthRule.group(2)!); + final weekday = int.parse(monthRule.group(3)!); + if (month < 1 || month > 12 || week < 1 || week > 5 || weekday > 6) { + throw const FormatException('Invalid POSIX month rule.'); + } + final first = DateTime.utc(year, month, 1); + var d = 1 + (weekday - first.weekday % 7 + 7) % 7 + (week - 1) * 7; + if (week == 5 && d > DateTime.utc(year, month + 1, 0).day) d -= 7; + day = DateTime.utc(year, month, d); + } else if (date.startsWith('J')) { + final n = int.parse(date.substring(1)); + if (n < 1 || n > 365) { + throw const FormatException('Invalid POSIX Julian rule.'); + } + final leap = + DateTime.utc( + year, + 3, + 1, + ).difference(DateTime.utc(year, 1, 1)).inDays == + 60; + day = DateTime.utc(year, 1, 1 + n - 1 + (leap && n >= 60 ? 1 : 0)); + } else { + final n = int.parse(date); + if (n < 0 || n > 365) { + throw const FormatException('Invalid POSIX day rule.'); + } + day = DateTime.utc(year, 1, 1 + n); + } + return day.add(Duration(seconds: seconds)); + } + + String? get icalRule { + final match = RegExp(r'^M(\d{1,2})\.(\d)\.(\d)$').firstMatch(date); + if (match == null || seconds < 0 || seconds >= 86400) return null; + final week = int.parse(match.group(2)!); + final weekday = const [ + 'SU', + 'MO', + 'TU', + 'WE', + 'TH', + 'FR', + 'SA', + ][int.parse(match.group(3)!)]; + return 'FREQ=YEARLY;BYMONTH=${match.group(1)};BYDAY=${week == 5 ? -1 : week}$weekday' + ';BYHOUR=${seconds ~/ 3600};BYMINUTE=${seconds ~/ 60 % 60};BYSECOND=${seconds % 60}'; + } +} + +String _dateLine( + String key, + String value, + String? zone, + bool allDay, { + String? targetZone, +}) { + if (allDay) { + final localized = targetZone != null && value.contains('T') + ? _exportWallFromInstant(_exportInstant(value, zone)!, targetZone) + : null; + final date = + localized ?? + DateTime.tryParse(value.length >= 10 ? value.substring(0, 10) : value); + if (date == null) throw const FormatException('Invalid all-day date.'); + return '$key;VALUE=DATE:${_date(date)}'; + } + final effectiveValue = _seriesWallValue(value, zone, targetZone); + final effectiveZone = targetZone ?? zone; + final wall = providerDateTimeAsWallTime(effectiveValue, effectiveZone); + if (wall == null) throw const FormatException('Invalid event date-time.'); + final safeZone = + windowsToIanaTimeZones[effectiveZone] ?? effectiveZone?.trim(); + if (safeZone != null && + safeZone.isNotEmpty && + !isUtcTimeZone(safeZone) && + RegExp(r'^[A-Za-z0-9_+./-]+$').hasMatch(safeZone)) { + providerInstantInTimeZone(DateTime.utc(2026), safeZone); + return '$key;TZID=$safeZone:${_wall(wall)}'; + } + if (safeZone != null && safeZone.isNotEmpty && !isUtcTimeZone(safeZone)) { + throw const FormatException('Unsupported event timezone.'); + } + if (safeZone == null && + !RegExp(r'(?:[zZ]|[+-]\d{2}(?::?\d{2})?)$').hasMatch(effectiveValue)) { + throw const FormatException( + 'A floating provider time cannot be exported safely.', + ); + } + final instant = _exportInstant(effectiveValue, effectiveZone); + if (instant == null) throw const FormatException('Unknown event timezone.'); + return '$key:${_utc(instant)}'; +} + +String _wallTimeSuffix(String? value) { + if (value == null) throw const FormatException('Series start is missing.'); + final match = RegExp(r'T(\d{2}:\d{2}:\d{2})').firstMatch(value); + if (match == null) { + throw const FormatException('Series start time is invalid.'); + } + return 'T${match.group(1)}'; +} + +String _date(DateTime value) => + '${value.year.toString().padLeft(4, '0')}' + '${value.month.toString().padLeft(2, '0')}' + '${value.day.toString().padLeft(2, '0')}'; + +String _wall(DateTime value) => + '${_date(value)}T' + '${value.hour.toString().padLeft(2, '0')}' + '${value.minute.toString().padLeft(2, '0')}' + '${value.second.toString().padLeft(2, '0')}'; + +String _utc(DateTime value) => '${_wall(value.toUtc())}Z'; + +String _text(String value) => value + .replaceAll('\\', r'\\') + .replaceAll('\r', '') + .replaceAll('\n', r'\n') + .replaceAll(';', r'\;') + .replaceAll(',', r'\,'); + +String _parameter(String value) => '"${_text(value).replaceAll('"', r'\"')}"'; + +bool _safeEmail(String value) => + RegExp(r'^[^\s@:/]+@[^\s@:/]+\.[^\s@:/]+$').hasMatch(value); + +bool _safeHttps(String? value) { + final uri = Uri.tryParse(value ?? ''); + return uri != null && + uri.scheme == 'https' && + uri.host.isNotEmpty && + uri.userInfo.isEmpty && + !value!.contains(RegExp(r'[\r\n]')); +} diff --git a/lib/src/features/schedule/presentation/google_event_attachment_reference.dart b/lib/src/features/schedule/presentation/google_event_attachment_reference.dart new file mode 100644 index 00000000..387c72db --- /dev/null +++ b/lib/src/features/schedule/presentation/google_event_attachment_reference.dart @@ -0,0 +1,58 @@ +import '../../../features/calendar/data/calendar_repository.dart'; +import '../../../google_calendar/google_calendar_api_client.dart'; +import '../../../providers/busy_provider.dart'; +import '../../../schedule/event_attachment_link.dart'; +import '../../../schedule/schedule_item.dart'; + +final class GoogleEventAttachmentReferenceChange { + const GoogleEventAttachmentReferenceChange({ + required this.links, + required this.cacheUpdated, + }); + + final List links; + final bool cacheUpdated; +} + +/// Changes an existing event's reference, never the underlying Drive file. +/// The provider request reads the complete authoritative array and uses its +/// ETag. A successful remote write is not reported as failed just because a +/// subsequent local projection could not be refreshed. +Future +changeGoogleEventAttachmentReference({ + required CalendarScheduleItem item, + required GoogleCalendarApiClient client, + required CalendarRepository repository, + String? addFileUrl, + String? addTitle, + String? removeFileUrl, +}) async { + final eventId = item.providerEventId; + if (item.provider != BusyProvider.google || + !item.capabilities.canEdit || + eventId == null || + eventId.isEmpty) { + throw UnsupportedError('This event cannot change attachment references.'); + } + final event = await client.changeEventAttachmentReferences( + calendarId: item.providerCalendarId, + eventId: eventId, + addFileUrl: addFileUrl, + addTitle: addTitle, + removeFileUrl: removeFileUrl, + ); + var cacheUpdated = true; + try { + await repository.upsertEvent( + accountId: item.accountId, + event: event, + preservePendingLocalChanges: true, + ); + } on Object { + cacheUpdated = false; + } + return GoogleEventAttachmentReferenceChange( + links: eventAttachmentLinks(event.attachmentsJson), + cacheUpdated: cacheUpdated, + ); +} diff --git a/lib/src/features/schedule/presentation/linux_event_attachments_dialog.dart b/lib/src/features/schedule/presentation/linux_event_attachments_dialog.dart new file mode 100644 index 00000000..76798b7d --- /dev/null +++ b/lib/src/features/schedule/presentation/linux_event_attachments_dialog.dart @@ -0,0 +1,503 @@ +import 'dart:async'; + +import 'package:file_selector/file_selector.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:yaru/yaru.dart'; + +import '../../../app/app_bootstrap.dart'; +import '../../../app/busymax_design.dart'; +import '../../../app/busymax_dialogs.dart'; +import '../../../l10n/l10n.dart'; +import '../../../microsoft_calendar/microsoft_event_attachment.dart'; +import '../../../providers/busy_provider.dart'; +import '../../../schedule/event_attachment_link.dart'; +import '../../../schedule/schedule_item.dart'; +import 'attachment_download.dart'; +import 'google_event_attachment_reference.dart'; +import 'schedule_event_details_format.dart'; +import 'attachment_upload_coordinator.dart'; + +Future showLinuxEventAttachmentsDialog( + BuildContext context, + CalendarScheduleItem item, +) => showBusyMaxModalDialog( + context, + builder: (_) => _LinuxEventAttachmentsDialog(item: item), +); + +class _LinuxEventAttachmentsDialog extends ConsumerStatefulWidget { + const _LinuxEventAttachmentsDialog({required this.item}); + final CalendarScheduleItem item; + @override + ConsumerState<_LinuxEventAttachmentsDialog> createState() => + _LinuxEventAttachmentsDialogState(); +} + +class _LinuxEventAttachmentsDialogState + extends ConsumerState<_LinuxEventAttachmentsDialog> { + bool _saving = false; + late List _referenceLinks; + + @override + void initState() { + super.initState(); + _referenceLinks = List.of(widget.item.attachmentLinks); + } + + @override + Widget build(BuildContext context) { + final item = widget.item; + final eventId = item.providerEventId; + final microsoft = item.provider == BusyProvider.microsoft; + final google = item.provider == BusyProvider.google; + final nextcloud = item.provider == BusyProvider.nextcloud; + final uploads = ref.watch(attachmentUploadCoordinatorProvider); + final uploadKey = eventId == null + ? null + : AttachmentUploadCoordinator.eventKey( + item.accountId, + item.providerCalendarId, + eventId, + ); + final result = !microsoft || eventId == null + ? null + : ref.watch( + microsoftEventAttachmentsProvider(( + accountId: item.accountId, + calendarId: item.providerCalendarId, + eventId: eventId, + )), + ); + return BusyMaxDialogShell( + title: context.l10n.attachments, + maxWidth: 520, + actions: [ + if ((microsoft || google || nextcloud) && + item.capabilities.canEdit && + (nextcloud || eventId != null)) + BusyMaxPushButton.standard( + key: const Key('event-attachment-add'), + onPressed: + _saving || + (microsoft && + uploadKey != null && + !uploads.canSubmit(uploadKey)) + ? null + : () => unawaited(_add()), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [const Icon(Icons.add), Text(context.l10n.attachments)], + ), + ), + BusyMaxPushButton.suggested( + onPressed: _saving ? null : () => Navigator.pop(context), + child: Text(context.l10n.close), + ), + ], + children: [ + if (microsoft && + uploadKey != null && + uploads.retryAfter(uploadKey) != null) + Text(context.l10n.attachmentUploadRateLimited), + if (microsoft && + uploadKey != null && + uploads.needsReconciliation(uploadKey)) ...[ + Text( + uploads.confirmedId(uploadKey) == null + ? context.l10n.attachmentUploadUnresolved + : context.l10n.completed, + ), + BusyMaxPushButton.standard( + onPressed: _saving + ? null + : () => unawaited(_reconcileUpload(eventId!)), + child: Text(context.l10n.refresh), + ), + if (uploads.hasResumableSession(uploadKey)) + BusyMaxPushButton.standard( + onPressed: _saving + ? null + : () => unawaited(_cancelUpload(eventId!)), + child: Text(context.l10n.cancel), + ), + if (uploads.canResolveManually(uploadKey)) ...[ + BusyMaxPushButton.standard( + onPressed: _saving + ? null + : () => unawaited(_resolveUpload(uploadKey, exists: true)), + child: Text(context.l10n.completed), + ), + BusyMaxPushButton.standard( + onPressed: _saving + ? null + : () => unawaited(_resolveUpload(uploadKey, exists: false)), + child: Text(context.l10n.retry), + ), + ], + ], + if (!microsoft) + if (_referenceLinks.isEmpty) + Text( + item.attachmentsLoaded + ? context.l10n.noneValue + : context.l10n.attachmentsNotLoaded, + ) + else + for (final link in _referenceLinks) + Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(link.name), + Wrap( + spacing: 8, + children: [ + BusyMaxPushButton.standard( + onPressed: () => unawaited(_open(link.url)), + child: Text(context.l10n.openInProvider), + ), + if ((google || nextcloud) && item.capabilities.canEdit) + BusyMaxPushButton.standard( + key: const Key('event-attachment-remove-reference'), + onPressed: _saving + ? null + : () => unawaited(_removeReference(link)), + child: Text(context.l10n.delete), + ), + ], + ), + ], + ), + ) + else if (result == null) + Text(context.l10n.attachmentsNotLoaded) + else + result.when( + loading: () => const Center(child: YaruCircularProgressIndicator()), + error: (error, _) => Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SelectableText('${context.l10n.attachmentsNotLoaded}\n$error'), + BusyMaxPushButton.standard( + onPressed: _refresh, + child: Text(context.l10n.retry), + ), + ], + ), + data: (attachments) => attachments.isEmpty + ? Text(context.l10n.noneValue) + : Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (final attachment in attachments) + Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Row( + children: [ + Expanded(child: Text(attachment.name)), + if (attachment.size case final size?) + Text('$size B'), + if (attachment.canDownload) + BusyMaxPushButton.standard( + onPressed: _saving + ? null + : () => unawaited(_save(attachment)), + child: Text(context.l10n.save), + ) + else if (attachment.kind == + MicrosoftEventAttachmentKind.reference && + attachment.sourceUrl != null) + BusyMaxPushButton.standard( + onPressed: () => + unawaited(_open(attachment.sourceUrl!)), + child: Text(context.l10n.openInProvider), + ), + if (item.capabilities.canEdit && + attachment.id.isNotEmpty) + BusyMaxPushButton.standard( + onPressed: _saving + ? null + : () => unawaited(_remove(attachment)), + child: Text(context.l10n.delete), + ), + ], + ), + ), + ], + ), + ), + ], + ); + } + + void _refresh() { + final eventId = widget.item.providerEventId; + if (eventId == null) return; + ref.invalidate( + microsoftEventAttachmentsProvider(( + accountId: widget.item.accountId, + calendarId: widget.item.providerCalendarId, + eventId: eventId, + )), + ); + } + + Future _add() async { + final eventId = widget.item.providerEventId; + if (!widget.item.capabilities.canEdit) return; + if (widget.item.provider == BusyProvider.google || + widget.item.provider == BusyProvider.nextcloud) { + final url = await showBusyMaxTextPrompt( + context, + title: context.l10n.attachments, + label: context.l10n.webLink, + actionLabel: context.l10n.save, + ); + if (url == null || !mounted) return; + await _changeReference(addFileUrl: url); + return; + } + if (eventId == null) return; + final file = await openFile(); + if (file == null || !mounted) return; + final name = safeAttachmentFileName(file.name); + if (name == null) { + _error(const FormatException('Invalid attachment name.')); + return; + } + setState(() => _saving = true); + try { + final size = await file.length(); + if (size > 150 * 1024 * 1024) { + throw StateError('Event file exceeds 150 MB.'); + } + await ref + .read(attachmentUploadCoordinatorProvider) + .uploadEvent( + client: ref.read( + microsoftCalendarApiClientForAccountProvider( + widget.item.accountId, + ), + ), + accountId: widget.item.accountId, + calendarId: widget.item.providerCalendarId, + eventId: eventId, + name: name, + contentType: file.mimeType ?? 'application/octet-stream', + bytes: await file.readAsBytes(), + ); + _refresh(); + } on Object catch (error) { + _refresh(); + _error(error); + } finally { + if (mounted) setState(() => _saving = false); + } + } + + Future _reconcileUpload(String eventId) async { + setState(() => _saving = true); + try { + await ref + .read(attachmentUploadCoordinatorProvider) + .reconcileEvent( + client: ref.read( + microsoftCalendarApiClientForAccountProvider( + widget.item.accountId, + ), + ), + accountId: widget.item.accountId, + calendarId: widget.item.providerCalendarId, + eventId: eventId, + ); + _refresh(); + } on Object catch (error) { + _error(error); + } finally { + if (mounted) setState(() => _saving = false); + } + } + + Future _cancelUpload(String eventId) async { + setState(() => _saving = true); + try { + await ref + .read(attachmentUploadCoordinatorProvider) + .cancelEvent( + client: ref.read( + microsoftCalendarApiClientForAccountProvider( + widget.item.accountId, + ), + ), + accountId: widget.item.accountId, + calendarId: widget.item.providerCalendarId, + eventId: eventId, + ); + _refresh(); + } on Object catch (error) { + _error(error); + } finally { + if (mounted) setState(() => _saving = false); + } + } + + Future _resolveUpload( + AttachmentUploadKey key, { + required bool exists, + }) async { + final confirmed = await showBusyMaxConfirm( + context, + title: exists ? context.l10n.completed : context.l10n.retry, + message: context.l10n.attachmentUploadUnresolved, + confirmLabel: exists ? context.l10n.completed : context.l10n.retry, + ); + if (confirmed != true || !mounted) return; + ref + .read(attachmentUploadCoordinatorProvider) + .resolveUncertainManually(key, exists: exists); + _refresh(); + } + + Future _removeReference(EventAttachmentLink link) async { + final confirmed = await showBusyMaxConfirm( + context, + title: context.l10n.delete, + message: link.name, + confirmLabel: context.l10n.delete, + destructive: true, + ); + if (!confirmed || !mounted) return; + await _changeReference(removeFileUrl: link.url); + } + + Future _changeReference({ + String? addFileUrl, + String? removeFileUrl, + }) async { + setState(() => _saving = true); + try { + final List links; + var cacheUpdated = true; + if (widget.item.provider == BusyProvider.google) { + final changed = await changeGoogleEventAttachmentReference( + item: widget.item, + client: ref.read( + googleCalendarApiClientForAccountProvider(widget.item.accountId), + ), + repository: ref.read(calendarRepositoryProvider), + addFileUrl: addFileUrl, + removeFileUrl: removeFileUrl, + ); + links = changed.links; + cacheUpdated = changed.cacheUpdated; + } else { + final detail = await ref + .read(calendarRepositoryProvider) + .changeNextcloudUriAttachmentReference( + accountId: widget.item.accountId, + eventId: widget.item.id, + addUrl: addFileUrl, + removeUrl: removeFileUrl, + ); + links = eventAttachmentLinks(detail?.attachments); + cacheUpdated = detail != null; + } + if (!mounted) return; + setState(() => _referenceLinks = links); + if (!cacheUpdated) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(context.l10n.refreshFailed(context.l10n.attachments)), + ), + ); + } + } on Object catch (error) { + _error(error); + } finally { + if (mounted) setState(() => _saving = false); + } + } + + Future _remove(MicrosoftEventAttachment attachment) async { + final eventId = widget.item.providerEventId; + if (eventId == null || !widget.item.capabilities.canEdit) return; + final confirmed = await showBusyMaxConfirm( + context, + title: context.l10n.delete, + message: attachment.name, + confirmLabel: context.l10n.delete, + destructive: true, + ); + if (!confirmed || !mounted) return; + setState(() => _saving = true); + try { + await ref + .read( + microsoftCalendarApiClientForAccountProvider(widget.item.accountId), + ) + .deleteEventAttachment( + calendarId: widget.item.providerCalendarId, + eventId: eventId, + attachmentId: attachment.id, + ); + ref + .read(attachmentUploadCoordinatorProvider) + .attachmentRemoved( + AttachmentUploadCoordinator.eventKey( + widget.item.accountId, + widget.item.providerCalendarId, + eventId, + ), + attachment.id, + ); + _refresh(); + } on Object catch (error) { + _refresh(); + _error(error); + } finally { + if (mounted) setState(() => _saving = false); + } + } + + void _error(Object error) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.exportFailed('$error'))), + ); + } + + Future _open(String url) async { + if (await openScheduleWebLink(url) || !mounted) return; + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(context.l10n.eventLinkOpenFailed))); + } + + Future _save(MicrosoftEventAttachment attachment) async { + final eventId = widget.item.providerEventId; + if (eventId == null) return; + setState(() => _saving = true); + try { + final bytes = await ref + .read( + microsoftCalendarApiClientForAccountProvider(widget.item.accountId), + ) + .downloadEventAttachment( + calendarId: widget.item.providerCalendarId, + eventId: eventId, + attachment: attachment, + ); + await saveAttachmentOnDesktop(name: attachment.name, bytes: bytes); + } on Object catch (error) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.exportFailed('$error'))), + ); + } + } finally { + if (mounted) setState(() => _saving = false); + } + } +} diff --git a/lib/src/features/schedule/presentation/schedule_event_details_format.dart b/lib/src/features/schedule/presentation/schedule_event_details_format.dart new file mode 100644 index 00000000..64f201c0 --- /dev/null +++ b/lib/src/features/schedule/presentation/schedule_event_details_format.dart @@ -0,0 +1,150 @@ +import 'package:flutter/widgets.dart'; +import 'package:intl/intl.dart'; +import 'package:url_launcher/url_launcher.dart'; + +import '../../../calendar_providers/calendar_description.dart'; +import '../../../l10n/l10n.dart'; +import '../../../l10n/time_format_scope.dart'; +import '../../../schedule/schedule_item.dart'; + +String scheduleEventIntervalLabel( + BuildContext context, + CalendarScheduleItem event, +) { + final start = event.start; + if (start == null) return context.l10n.noDate; + final locale = Localizations.localeOf(context).toLanguageTag(); + String date(DateTime value) => DateFormat.yMMMd(locale).format(value); + final end = event.end; + if (event.allDay) { + // iCalendar DTEND is exclusive. Subtract a civil day, not 24 hours, so + // daylight-saving transitions cannot add or remove a displayed date. + final last = end == null + ? start + : DateTime(end.year, end.month, end.day - 1); + final range = last.isAfter(start) + ? '${date(start)} – ${date(last)}' + : date(start); + return '$range · ${context.l10n.allDay}'; + } + final first = formatClockDateTime(context, start, date(start)); + final last = end == null + ? null + : formatClockDateTime(context, end, date(end)); + // Schedule projections are already in device-local time. The provider's + // original TZID may differ and must not label these displayed wall values. + final startZone = _displayTimeZone(start); + if (end == null || last == null) return '$first · $startZone'; + final endZone = _displayTimeZone(end); + if (startZone == endZone) return '$first – $last · $startZone'; + return '$first $startZone – $last $endZone'; +} + +String _displayTimeZone(DateTime value) { + final offset = value.timeZoneOffset; + final name = value.timeZoneName.trim(); + if (offset == Duration.zero) return 'UTC'; + final absolute = offset.abs(); + final hours = absolute.inHours.toString().padLeft(2, '0'); + final minutes = (absolute.inMinutes % 60).toString().padLeft(2, '0'); + final utcOffset = 'UTC${offset.isNegative ? '-' : '+'}$hours:$minutes'; + return name.isEmpty || name == 'UTC' ? utcOffset : '$name ($utcOffset)'; +} + +String calendarEventDescription(CalendarScheduleItem event) { + final html = event.descriptionHtml; + if (html != null && isHtmlContentType(event.descriptionContentType)) { + return htmlCalendarDescriptionToPlainText(html); + } + return event.description ?? ''; +} + +List calendarEventDescriptionLinks(CalendarScheduleItem event) { + final found = {}; + final text = calendarEventDescription(event); + for (final match in RegExp( + r'''https?://[^\s<>"']+''', + caseSensitive: false, + ).allMatches(text)) { + found.add(match.group(0)!.replaceFirst(RegExp(r'[.,;:!?]+$'), '')); + } + final html = event.descriptionHtml; + if (html != null && isHtmlContentType(event.descriptionContentType)) { + for (final match in RegExp( + r''']*\bhref\s*=\s*["']([^"']+)["']''', + caseSensitive: false, + ).allMatches(html)) { + found.add(decodeHtmlEntities(match.group(1)!)); + } + } + return [ + for (final value in found) + if (safeScheduleWebUri(value) case final uri?) uri, + ]; +} + +Uri? safeScheduleWebUri(String? value) { + final uri = value == null ? null : Uri.tryParse(value.trim()); + if (uri == null || + (uri.scheme != 'https' && uri.scheme != 'http') || + uri.host.isEmpty || + uri.userInfo.isNotEmpty) { + return null; + } + return uri; +} + +Future openScheduleWebLink(String? value) async { + final uri = safeScheduleWebUri(value); + if (uri == null) return false; + try { + return await launchUrl(uri, mode: LaunchMode.externalApplication); + } on Object { + return false; + } +} + +String scheduleEventPersonName(Map? person) { + if (person == null) return ''; + final emailAddress = person['emailAddress']; + final nested = emailAddress is Map ? emailAddress : const {}; + for (final value in [ + person['displayName'], + nested['name'], + person['email'], + nested['address'], + person['value'], + ]) { + final text = value?.toString().trim(); + if (text != null && text.isNotEmpty) return text; + } + return ''; +} + +String scheduleEventResponseLabel(BuildContext context, String? response) { + return switch (response?.toLowerCase()) { + 'accepted' => context.l10n.responseAccepted, + 'tentative' || 'tentativelyaccepted' => context.l10n.responseTentative, + 'declined' => context.l10n.responseDeclined, + 'needsaction' => context.l10n.responseNeedsAction, + 'organizer' => context.l10n.responseOrganizer, + 'none' || 'notresponded' || null => context.l10n.responseNotResponded, + final value => value, + }; +} + +String scheduleEventAttendeeLabel( + BuildContext context, + Map attendee, +) { + final statusObject = attendee['status']; + final status = + attendee['responseStatus']?.toString() ?? + (statusObject is Map ? statusObject['response']?.toString() : null); + final optional = + attendee['optional'] == true || + attendee['type']?.toString().toLowerCase() == 'optional'; + final name = scheduleEventPersonName(attendee); + return '$name — ${scheduleEventResponseLabel(context, status)}' + '${optional ? ' · ${context.l10n.attendeeOptional}' : ''}'; +} diff --git a/lib/src/features/schedule/presentation/schedule_item_details_popover.dart b/lib/src/features/schedule/presentation/schedule_item_details_popover.dart index d8c5bc65..a9001f7f 100644 --- a/lib/src/features/schedule/presentation/schedule_item_details_popover.dart +++ b/lib/src/features/schedule/presentation/schedule_item_details_popover.dart @@ -7,17 +7,21 @@ import '../../../app/busymax_design.dart'; import '../../../calendar_providers/calendar_description.dart'; import '../../../l10n/l10n.dart'; import '../../../schedule/schedule_item.dart'; +import '../../../providers/busy_provider.dart'; import '../../../schedule/schedule_projection.dart'; import '../../maps/application/external_location_launcher.dart'; import '../application/saved_schedule_location.dart'; import 'schedule_anchored_popover.dart'; import 'schedule_event_block.dart'; +import 'schedule_event_details_format.dart'; +import '../../calendar/presentation/google_status_event_labels.dart'; enum ScheduleItemDetailsAction { export, edit, delete, joinMeeting, + attachments, acceptInvitation, tentativeInvitation, declineInvitation, @@ -146,6 +150,17 @@ class _ScheduleItemDetailsPopoverCard extends StatelessWidget { ), ), ], + if (item case final CalendarScheduleItem event + when event.provider == BusyProvider.microsoft && + event.providerEventId != null) ...[ + const SizedBox(height: BusyMaxSpacing.xs), + BusyMaxPushButton.standard( + onPressed: () => Navigator.of( + context, + ).pop(ScheduleItemDetailsAction.attachments), + child: Text(context.l10n.attachments), + ), + ], if (item case final CalendarScheduleItem event when event.canRespondToInvitation) ...[ const SizedBox(height: BusyMaxSpacing.md), @@ -318,7 +333,9 @@ class _ScheduleItemDetails extends StatelessWidget { final details = [ _ScheduleDetailRow( icon: Icons.schedule, - text: _dateTimeLabel(context, item), + text: item is CalendarScheduleItem + ? scheduleEventIntervalLabel(context, item as CalendarScheduleItem) + : _dateTimeLabel(context, item), ), _ScheduleDetailRow( icon: item is TaskScheduleItem @@ -422,8 +439,6 @@ class _ScheduleDetailRichRow extends StatelessWidget { Expanded( child: Text.rich( _descriptionTextSpan(text, document.ranges, baseStyle), - maxLines: 5, - overflow: TextOverflow.ellipsis, ), ), ], @@ -511,6 +526,12 @@ List _eventDetails( _attendeeResponseLabel(context, attendee), ]; return [ + for (final line in googleStatusDetailLines( + context.l10n, + item.eventType, + item.googleStatusProperties, + )) + _ScheduleDetailRow(icon: Icons.event_note_outlined, text: line), if (locationDestination != null) _ScheduleLocationDetailRow(item: item, destination: locationDestination) else if (scheduleItemLocationText(item).trim().isNotEmpty) @@ -572,10 +593,47 @@ List _eventDetails( fallbackText: description ?? '', ) else if (description != null && description.isNotEmpty) - _ScheduleDetailRow(icon: Icons.notes, text: description), + _ScheduleDetailRow(icon: Icons.notes, text: description, maxLines: null), + for (final uri in calendarEventDescriptionLinks(item)) + _ScheduleWebLinkButton(label: uri.toString(), url: uri.toString()), + if (item.eventLinkUrl case final link?) + _ScheduleWebLinkButton(label: context.l10n.eventLink, url: link), + if (item.attachmentsMayExist || item.attachmentLinks.isNotEmpty) + _ScheduleDetailRow( + icon: Icons.attach_file, + text: context.l10n.attachments, + ), + for (final attachment in item.attachmentLinks) + _ScheduleWebLinkButton(label: attachment.name, url: attachment.url), + if (item.attachmentsMayExist && !item.attachmentsLoaded) + _ScheduleDetailRow( + icon: Icons.info_outline, + text: context.l10n.attachmentsNotLoaded, + ), ]; } +class _ScheduleWebLinkButton extends StatelessWidget { + const _ScheduleWebLinkButton({required this.label, required this.url}); + + final String label; + final String url; + + @override + Widget build(BuildContext context) => Align( + alignment: AlignmentDirectional.centerStart, + child: BusyMaxPushButton.standard( + onPressed: () async { + if (await openScheduleWebLink(url) || !context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.eventLinkOpenFailed)), + ); + }, + child: Text(label, overflow: TextOverflow.ellipsis), + ), + ); +} + String? _organizerName(Map? organizer) { if (organizer == null) return null; final emailAddress = organizer['emailAddress']; @@ -663,6 +721,13 @@ List _taskDetails( ), if (notes != null && notes.isNotEmpty) _ScheduleDetailRow(icon: Icons.notes, text: notes), + for (final link in item.availableSourceLinks) + _ScheduleWebLinkButton( + label: link.label?.isNotEmpty == true + ? link.label! + : context.l10n.openInProvider, + url: link.url, + ), ]; } diff --git a/lib/src/features/schedule/presentation/schedule_workspace.dart b/lib/src/features/schedule/presentation/schedule_workspace.dart index bc226726..50cb4050 100644 --- a/lib/src/features/schedule/presentation/schedule_workspace.dart +++ b/lib/src/features/schedule/presentation/schedule_workspace.dart @@ -3,7 +3,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter/services.dart'; import 'package:go_router/go_router.dart'; -import 'package:url_launcher/url_launcher.dart'; import '../../../app/app_bootstrap.dart'; import '../../../app/busymax_about_dialog.dart'; @@ -57,7 +56,10 @@ import 'schedule_create_menu.dart'; import 'schedule_day_week_view.dart'; import 'schedule_empty_states.dart'; import 'schedule_item_details_popover.dart'; +import 'linux_event_attachments_dialog.dart'; +import 'schedule_event_details_format.dart'; import 'schedule_item_exporter.dart'; +import 'cloud_calendar_series_export.dart'; import 'schedule_item_selection.dart'; import 'schedule_month_view.dart'; import 'schedule_sidebar.dart'; @@ -652,9 +654,53 @@ class _ScheduleWorkspaceState extends ConsumerState { searchActive && _searchCriteria != null, child: buildSidebar(), ); + final cloudCoverage = + snapshot.hasData && + !scheduleLoading && + _searchCriteria?.date != + ScheduleSearchDate.any + ? ref + .read(scheduleRepositoryProvider) + .cloudCoverageCompleteFor( + _searchCriteria?.range ?? range, + filters: + searchActive && + _searchCriteria != null + ? _searchCriteria!.filters( + _searchQuery, + ) + : ScheduleFilters( + accountIds: accountIds.toSet(), + sourceIds: visibility + .visibleCalendarSourceIds, + sourceFilterActive: true, + ), + ) + : true; final frame = LinuxPageFrame( header: header, - body: main, + body: cloudCoverage != true + ? Column( + children: [ + ListTile( + leading: Icon( + cloudCoverage == null + ? Icons.sync + : Icons.cloud_off, + ), + title: Text( + cloudCoverage == null + ? context.l10n.scheduleLoading + : context + .l10n + .scheduleRangeIncomplete, + ), + dense: true, + ), + Expanded(child: main), + ], + ) + : main, sidebarHeader: const BusyMaxLinuxBrandHeader(), sidebarBody: sidebar, sidebarAvailable: showSidebar, @@ -1636,6 +1682,10 @@ class _ScheduleWorkspaceState extends ConsumerState { if (item is CalendarScheduleItem) { await _joinMeeting(item); } + case ScheduleItemDetailsAction.attachments: + if (item is CalendarScheduleItem) { + await showLinuxEventAttachmentsDialog(context, item); + } case ScheduleItemDetailsAction.acceptInvitation: if (item is CalendarScheduleItem) { await _respondToInvitation(item, CalendarInvitationResponse.accept); @@ -1703,9 +1753,43 @@ class _ScheduleWorkspaceState extends ConsumerState { try { String? rawICalendar; if (item is CalendarScheduleItem) { - rawICalendar = await ref - .read(calendarRepositoryProvider) - .nativeEventExport(item.id); + final repository = ref.read(calendarRepositoryProvider); + final detail = await repository.loadEventDetail(item.id); + final recurring = + detail != null && + (detail.recurrence != null || + detail.providerRecurringEventId != null); + final series = + recurring && + canExportAuthoritativeEventSeries( + provider: item.provider, + providerEventId: item.providerEventId, + davCollectionId: detail.davCollectionId, + ) + ? await _chooseSeriesExport() + : false; + if (series == null) return; + if (series && item.provider == BusyProvider.google) { + rawICalendar = await exportGoogleEventSeries( + client: ref.read( + googleCalendarApiClientForAccountProvider(item.accountId), + ), + calendarId: item.providerCalendarId, + eventId: item.providerEventId!, + nowUtc: DateTime.now().toUtc(), + ); + } else if (series && item.provider == BusyProvider.microsoft) { + rawICalendar = await exportMicrosoftEventSeries( + client: ref.read( + microsoftCalendarApiClientForAccountProvider(item.accountId), + ), + calendarId: item.providerCalendarId, + eventId: item.providerEventId!, + nowUtc: DateTime.now().toUtc(), + ); + } else if (series || !recurring) { + rawICalendar = await repository.nativeEventExport(item.id); + } } if (item is TaskScheduleItem && (item.provider == BusyProvider.nextcloud || @@ -1740,6 +1824,33 @@ class _ScheduleWorkspaceState extends ConsumerState { } } + Future _chooseSeriesExport() => showBusyMaxModalEditorDialog( + context, + maxHeight: 300, + builder: (dialogContext) => BusyMaxModalEditorScaffold( + title: context.l10n.export, + cancelLabel: context.l10n.cancel, + saveLabel: context.l10n.export, + onCancel: () => Navigator.of(dialogContext).pop(), + onSave: null, + children: [ + BusyMaxGroupedList( + filled: true, + children: [ + BusyMaxActionRow( + title: context.l10n.singleOccurrence, + onTap: () => Navigator.of(dialogContext).pop(false), + ), + BusyMaxActionRow( + title: context.l10n.entireSeries, + onTap: () => Navigator.of(dialogContext).pop(true), + ), + ], + ), + ], + ), + ); + void _openTaskDetails(TaskScheduleItem item) { unawaited( _openTaskDetailsTarget( @@ -2248,7 +2359,9 @@ class _ScheduleWorkspaceState extends ConsumerState { message: item is CalendarScheduleItem && item.isNextcloudAttendee ? context.l10n.nextcloudDeclineRemovalWarning : item is TaskScheduleItem - ? context.l10n.deleteTaskConfirmation(item.title) + ? item.isAssigned + ? '${context.l10n.deleteTaskConfirmation(item.title)}\n\n${context.l10n.deleteAssignedTaskWarning}' + : context.l10n.deleteTaskConfirmation(item.title) : context.l10n.deleteCalendarConfirmation(item.title), confirmLabel: item is CalendarScheduleItem && item.isNextcloudAttendee ? context.l10n.nextcloudDeclineAndRemove @@ -2277,7 +2390,11 @@ class _ScheduleWorkspaceState extends ConsumerState { try { await ref .read(tasksRepositoryForAccountProvider(item.accountId)) - .deleteTask(item.sourceId, item.id); + .deleteTask( + item.sourceId, + item.id, + confirmedAssignedSourceDeletion: item.isAssigned, + ); if (mounted) setState(() {}); } on Object { if (mounted && @@ -2323,16 +2440,7 @@ class _ScheduleWorkspaceState extends ConsumerState { } Future _joinMeeting(CalendarScheduleItem item) async { - final value = item.joinMeetingUrl; - final uri = value == null ? null : Uri.tryParse(value); - var opened = false; - if (uri != null) { - try { - opened = await launchUrl(uri, mode: LaunchMode.externalApplication); - } on Object { - opened = false; - } - } + final opened = await openScheduleWebLink(item.joinMeetingUrl); if (!opened && mounted) { ScaffoldMessenger.of( context, diff --git a/lib/src/features/schedule/presentation/tz_continuations_2025c.dart b/lib/src/features/schedule/presentation/tz_continuations_2025c.dart new file mode 100644 index 00000000..5880d300 --- /dev/null +++ b/lib/src/features/schedule/presentation/tz_continuations_2025c.dart @@ -0,0 +1,604 @@ +// Generated from IANA tzdata2025c rearguard.zi (zic -b fat). +// Source: https://data.iana.org/time-zones/releases/tzdata2025c.tar.gz +// SHA-256: 4aa79e4effee53fc4029ffe5f6ebe97937282ebcdf386d5d2da91ce84142f957 +// Run tool/generate_tz_continuations.dart to reproduce. +const tzContinuations2025c = { + 'Africa/Abidjan': 'GMT0', + 'Africa/Accra': 'GMT0', + 'Africa/Addis_Ababa': 'EAT-3', + 'Africa/Algiers': 'CET-1', + 'Africa/Asmara': 'EAT-3', + 'Africa/Asmera': 'EAT-3', + 'Africa/Bamako': 'GMT0', + 'Africa/Bangui': 'WAT-1', + 'Africa/Banjul': 'GMT0', + 'Africa/Bissau': 'GMT0', + 'Africa/Blantyre': 'CAT-2', + 'Africa/Brazzaville': 'WAT-1', + 'Africa/Bujumbura': 'CAT-2', + 'Africa/Cairo': 'EET-2EEST,M4.5.5/0,M10.5.4/24', + 'Africa/Casablanca': '<+00>0<+01>,0/0,J365/25', + 'Africa/Ceuta': 'CET-1CEST,M3.5.0,M10.5.0/3', + 'Africa/Conakry': 'GMT0', + 'Africa/Dakar': 'GMT0', + 'Africa/Dar_es_Salaam': 'EAT-3', + 'Africa/Djibouti': 'EAT-3', + 'Africa/Douala': 'WAT-1', + 'Africa/El_Aaiun': '<+00>0<+01>,0/0,J365/25', + 'Africa/Freetown': 'GMT0', + 'Africa/Gaborone': 'CAT-2', + 'Africa/Harare': 'CAT-2', + 'Africa/Johannesburg': 'SAST-2', + 'Africa/Juba': 'CAT-2', + 'Africa/Kampala': 'EAT-3', + 'Africa/Khartoum': 'CAT-2', + 'Africa/Kigali': 'CAT-2', + 'Africa/Kinshasa': 'WAT-1', + 'Africa/Lagos': 'WAT-1', + 'Africa/Libreville': 'WAT-1', + 'Africa/Lome': 'GMT0', + 'Africa/Luanda': 'WAT-1', + 'Africa/Lubumbashi': 'CAT-2', + 'Africa/Lusaka': 'CAT-2', + 'Africa/Malabo': 'WAT-1', + 'Africa/Maputo': 'CAT-2', + 'Africa/Maseru': 'SAST-2', + 'Africa/Mbabane': 'SAST-2', + 'Africa/Mogadishu': 'EAT-3', + 'Africa/Monrovia': 'GMT0', + 'Africa/Nairobi': 'EAT-3', + 'Africa/Ndjamena': 'WAT-1', + 'Africa/Niamey': 'WAT-1', + 'Africa/Nouakchott': 'GMT0', + 'Africa/Ouagadougou': 'GMT0', + 'Africa/Porto-Novo': 'WAT-1', + 'Africa/Sao_Tome': 'GMT0', + 'Africa/Timbuktu': 'GMT0', + 'Africa/Tripoli': 'EET-2', + 'Africa/Tunis': 'CET-1', + 'Africa/Windhoek': 'CAT-2', + 'America/Adak': 'HST10HDT,M3.2.0,M11.1.0', + 'America/Anchorage': 'AKST9AKDT,M3.2.0,M11.1.0', + 'America/Anguilla': 'AST4', + 'America/Antigua': 'AST4', + 'America/Araguaina': '<-03>3', + 'America/Argentina/Buenos_Aires': '<-03>3', + 'America/Argentina/Catamarca': '<-03>3', + 'America/Argentina/ComodRivadavia': '<-03>3', + 'America/Argentina/Cordoba': '<-03>3', + 'America/Argentina/Jujuy': '<-03>3', + 'America/Argentina/La_Rioja': '<-03>3', + 'America/Argentina/Mendoza': '<-03>3', + 'America/Argentina/Rio_Gallegos': '<-03>3', + 'America/Argentina/Salta': '<-03>3', + 'America/Argentina/San_Juan': '<-03>3', + 'America/Argentina/San_Luis': '<-03>3', + 'America/Argentina/Tucuman': '<-03>3', + 'America/Argentina/Ushuaia': '<-03>3', + 'America/Aruba': 'AST4', + 'America/Asuncion': '<-03>3', + 'America/Atikokan': 'EST5', + 'America/Atka': 'HST10HDT,M3.2.0,M11.1.0', + 'America/Bahia': '<-03>3', + 'America/Bahia_Banderas': 'CST6', + 'America/Barbados': 'AST4', + 'America/Belem': '<-03>3', + 'America/Belize': 'CST6', + 'America/Blanc-Sablon': 'AST4', + 'America/Boa_Vista': '<-04>4', + 'America/Bogota': '<-05>5', + 'America/Boise': 'MST7MDT,M3.2.0,M11.1.0', + 'America/Buenos_Aires': '<-03>3', + 'America/Cambridge_Bay': 'MST7MDT,M3.2.0,M11.1.0', + 'America/Campo_Grande': '<-04>4', + 'America/Cancun': 'EST5', + 'America/Caracas': '<-04>4', + 'America/Catamarca': '<-03>3', + 'America/Cayenne': '<-03>3', + 'America/Cayman': 'EST5', + 'America/Chicago': 'CST6CDT,M3.2.0,M11.1.0', + 'America/Chihuahua': 'CST6', + 'America/Ciudad_Juarez': 'MST7MDT,M3.2.0,M11.1.0', + 'America/Coral_Harbour': 'EST5', + 'America/Cordoba': '<-03>3', + 'America/Costa_Rica': 'CST6', + 'America/Coyhaique': '<-03>3', + 'America/Creston': 'MST7', + 'America/Cuiaba': '<-04>4', + 'America/Curacao': 'AST4', + 'America/Danmarkshavn': 'GMT0', + 'America/Dawson': 'MST7', + 'America/Dawson_Creek': 'MST7', + 'America/Denver': 'MST7MDT,M3.2.0,M11.1.0', + 'America/Detroit': 'EST5EDT,M3.2.0,M11.1.0', + 'America/Dominica': 'AST4', + 'America/Edmonton': 'MST7MDT,M3.2.0,M11.1.0', + 'America/Eirunepe': '<-05>5', + 'America/El_Salvador': 'CST6', + 'America/Ensenada': 'PST8PDT,M3.2.0,M11.1.0', + 'America/Fort_Nelson': 'MST7', + 'America/Fort_Wayne': 'EST5EDT,M3.2.0,M11.1.0', + 'America/Fortaleza': '<-03>3', + 'America/Glace_Bay': 'AST4ADT,M3.2.0,M11.1.0', + 'America/Godthab': '<-02>2<-01>,M3.5.0/-1,M10.5.0/0', + 'America/Goose_Bay': 'AST4ADT,M3.2.0,M11.1.0', + 'America/Grand_Turk': 'EST5EDT,M3.2.0,M11.1.0', + 'America/Grenada': 'AST4', + 'America/Guadeloupe': 'AST4', + 'America/Guatemala': 'CST6', + 'America/Guayaquil': '<-05>5', + 'America/Guyana': '<-04>4', + 'America/Halifax': 'AST4ADT,M3.2.0,M11.1.0', + 'America/Havana': 'CST5CDT,M3.2.0/0,M11.1.0/1', + 'America/Hermosillo': 'MST7', + 'America/Indiana/Indianapolis': 'EST5EDT,M3.2.0,M11.1.0', + 'America/Indiana/Knox': 'CST6CDT,M3.2.0,M11.1.0', + 'America/Indiana/Marengo': 'EST5EDT,M3.2.0,M11.1.0', + 'America/Indiana/Petersburg': 'EST5EDT,M3.2.0,M11.1.0', + 'America/Indiana/Tell_City': 'CST6CDT,M3.2.0,M11.1.0', + 'America/Indiana/Vevay': 'EST5EDT,M3.2.0,M11.1.0', + 'America/Indiana/Vincennes': 'EST5EDT,M3.2.0,M11.1.0', + 'America/Indiana/Winamac': 'EST5EDT,M3.2.0,M11.1.0', + 'America/Indianapolis': 'EST5EDT,M3.2.0,M11.1.0', + 'America/Inuvik': 'MST7MDT,M3.2.0,M11.1.0', + 'America/Iqaluit': 'EST5EDT,M3.2.0,M11.1.0', + 'America/Jamaica': 'EST5', + 'America/Jujuy': '<-03>3', + 'America/Juneau': 'AKST9AKDT,M3.2.0,M11.1.0', + 'America/Kentucky/Louisville': 'EST5EDT,M3.2.0,M11.1.0', + 'America/Kentucky/Monticello': 'EST5EDT,M3.2.0,M11.1.0', + 'America/Knox_IN': 'CST6CDT,M3.2.0,M11.1.0', + 'America/Kralendijk': 'AST4', + 'America/La_Paz': '<-04>4', + 'America/Lima': '<-05>5', + 'America/Los_Angeles': 'PST8PDT,M3.2.0,M11.1.0', + 'America/Louisville': 'EST5EDT,M3.2.0,M11.1.0', + 'America/Lower_Princes': 'AST4', + 'America/Maceio': '<-03>3', + 'America/Managua': 'CST6', + 'America/Manaus': '<-04>4', + 'America/Marigot': 'AST4', + 'America/Martinique': 'AST4', + 'America/Matamoros': 'CST6CDT,M3.2.0,M11.1.0', + 'America/Mazatlan': 'MST7', + 'America/Mendoza': '<-03>3', + 'America/Menominee': 'CST6CDT,M3.2.0,M11.1.0', + 'America/Merida': 'CST6', + 'America/Metlakatla': 'AKST9AKDT,M3.2.0,M11.1.0', + 'America/Mexico_City': 'CST6', + 'America/Miquelon': '<-03>3<-02>,M3.2.0,M11.1.0', + 'America/Moncton': 'AST4ADT,M3.2.0,M11.1.0', + 'America/Monterrey': 'CST6', + 'America/Montevideo': '<-03>3', + 'America/Montreal': 'EST5EDT,M3.2.0,M11.1.0', + 'America/Montserrat': 'AST4', + 'America/Nassau': 'EST5EDT,M3.2.0,M11.1.0', + 'America/New_York': 'EST5EDT,M3.2.0,M11.1.0', + 'America/Nipigon': 'EST5EDT,M3.2.0,M11.1.0', + 'America/Nome': 'AKST9AKDT,M3.2.0,M11.1.0', + 'America/Noronha': '<-02>2', + 'America/North_Dakota/Beulah': 'CST6CDT,M3.2.0,M11.1.0', + 'America/North_Dakota/Center': 'CST6CDT,M3.2.0,M11.1.0', + 'America/North_Dakota/New_Salem': 'CST6CDT,M3.2.0,M11.1.0', + 'America/Nuuk': '<-02>2<-01>,M3.5.0/-1,M10.5.0/0', + 'America/Ojinaga': 'CST6CDT,M3.2.0,M11.1.0', + 'America/Panama': 'EST5', + 'America/Pangnirtung': 'EST5EDT,M3.2.0,M11.1.0', + 'America/Paramaribo': '<-03>3', + 'America/Phoenix': 'MST7', + 'America/Port-au-Prince': 'EST5EDT,M3.2.0,M11.1.0', + 'America/Port_of_Spain': 'AST4', + 'America/Porto_Acre': '<-05>5', + 'America/Porto_Velho': '<-04>4', + 'America/Puerto_Rico': 'AST4', + 'America/Punta_Arenas': '<-03>3', + 'America/Rainy_River': 'CST6CDT,M3.2.0,M11.1.0', + 'America/Rankin_Inlet': 'CST6CDT,M3.2.0,M11.1.0', + 'America/Recife': '<-03>3', + 'America/Regina': 'CST6', + 'America/Resolute': 'CST6CDT,M3.2.0,M11.1.0', + 'America/Rio_Branco': '<-05>5', + 'America/Rosario': '<-03>3', + 'America/Santa_Isabel': 'PST8PDT,M3.2.0,M11.1.0', + 'America/Santarem': '<-03>3', + 'America/Santiago': '<-04>4<-03>,M9.1.6/24,M4.1.6/24', + 'America/Santo_Domingo': 'AST4', + 'America/Sao_Paulo': '<-03>3', + 'America/Scoresbysund': '<-02>2<-01>,M3.5.0/-1,M10.5.0/0', + 'America/Shiprock': 'MST7MDT,M3.2.0,M11.1.0', + 'America/Sitka': 'AKST9AKDT,M3.2.0,M11.1.0', + 'America/St_Barthelemy': 'AST4', + 'America/St_Johns': 'NST3:30NDT,M3.2.0,M11.1.0', + 'America/St_Kitts': 'AST4', + 'America/St_Lucia': 'AST4', + 'America/St_Thomas': 'AST4', + 'America/St_Vincent': 'AST4', + 'America/Swift_Current': 'CST6', + 'America/Tegucigalpa': 'CST6', + 'America/Thule': 'AST4ADT,M3.2.0,M11.1.0', + 'America/Thunder_Bay': 'EST5EDT,M3.2.0,M11.1.0', + 'America/Tijuana': 'PST8PDT,M3.2.0,M11.1.0', + 'America/Toronto': 'EST5EDT,M3.2.0,M11.1.0', + 'America/Tortola': 'AST4', + 'America/Vancouver': 'PST8PDT,M3.2.0,M11.1.0', + 'America/Virgin': 'AST4', + 'America/Whitehorse': 'MST7', + 'America/Winnipeg': 'CST6CDT,M3.2.0,M11.1.0', + 'America/Yakutat': 'AKST9AKDT,M3.2.0,M11.1.0', + 'America/Yellowknife': 'MST7MDT,M3.2.0,M11.1.0', + 'Antarctica/Casey': '<+08>-8', + 'Antarctica/Davis': '<+07>-7', + 'Antarctica/DumontDUrville': '<+10>-10', + 'Antarctica/Macquarie': 'AEST-10AEDT,M10.1.0,M4.1.0/3', + 'Antarctica/Mawson': '<+05>-5', + 'Antarctica/McMurdo': 'NZST-12NZDT,M9.5.0,M4.1.0/3', + 'Antarctica/Palmer': '<-03>3', + 'Antarctica/Rothera': '<-03>3', + 'Antarctica/South_Pole': 'NZST-12NZDT,M9.5.0,M4.1.0/3', + 'Antarctica/Syowa': '<+03>-3', + 'Antarctica/Troll': '<+00>0<+02>-2,M3.5.0/1,M10.5.0/3', + 'Antarctica/Vostok': '<+05>-5', + 'Arctic/Longyearbyen': 'CET-1CEST,M3.5.0,M10.5.0/3', + 'Asia/Aden': '<+03>-3', + 'Asia/Almaty': '<+05>-5', + 'Asia/Amman': '<+03>-3', + 'Asia/Anadyr': '<+12>-12', + 'Asia/Aqtau': '<+05>-5', + 'Asia/Aqtobe': '<+05>-5', + 'Asia/Ashgabat': '<+05>-5', + 'Asia/Ashkhabad': '<+05>-5', + 'Asia/Atyrau': '<+05>-5', + 'Asia/Baghdad': '<+03>-3', + 'Asia/Bahrain': '<+03>-3', + 'Asia/Baku': '<+04>-4', + 'Asia/Bangkok': '<+07>-7', + 'Asia/Barnaul': '<+07>-7', + 'Asia/Beirut': 'EET-2EEST,M3.5.0/0,M10.5.0/0', + 'Asia/Bishkek': '<+06>-6', + 'Asia/Brunei': '<+08>-8', + 'Asia/Calcutta': 'IST-5:30', + 'Asia/Chita': '<+09>-9', + 'Asia/Choibalsan': '<+08>-8', + 'Asia/Chongqing': 'CST-8', + 'Asia/Chungking': 'CST-8', + 'Asia/Colombo': '<+0530>-5:30', + 'Asia/Dacca': '<+06>-6', + 'Asia/Damascus': '<+03>-3', + 'Asia/Dhaka': '<+06>-6', + 'Asia/Dili': '<+09>-9', + 'Asia/Dubai': '<+04>-4', + 'Asia/Dushanbe': '<+05>-5', + 'Asia/Famagusta': 'EET-2EEST,M3.5.0/3,M10.5.0/4', + 'Asia/Gaza': 'EET-2EEST,M3.4.4/50,M10.4.4/50', + 'Asia/Harbin': 'CST-8', + 'Asia/Hebron': 'EET-2EEST,M3.4.4/50,M10.4.4/50', + 'Asia/Ho_Chi_Minh': '<+07>-7', + 'Asia/Hong_Kong': 'HKT-8', + 'Asia/Hovd': '<+07>-7', + 'Asia/Irkutsk': '<+08>-8', + 'Asia/Istanbul': '<+03>-3', + 'Asia/Jakarta': 'WIB-7', + 'Asia/Jayapura': 'WIT-9', + 'Asia/Jerusalem': 'IST-2IDT,M3.4.4/26,M10.5.0', + 'Asia/Kabul': '<+0430>-4:30', + 'Asia/Kamchatka': '<+12>-12', + 'Asia/Karachi': 'PKT-5', + 'Asia/Kashgar': '<+06>-6', + 'Asia/Kathmandu': '<+0545>-5:45', + 'Asia/Katmandu': '<+0545>-5:45', + 'Asia/Khandyga': '<+09>-9', + 'Asia/Kolkata': 'IST-5:30', + 'Asia/Krasnoyarsk': '<+07>-7', + 'Asia/Kuala_Lumpur': '<+08>-8', + 'Asia/Kuching': '<+08>-8', + 'Asia/Kuwait': '<+03>-3', + 'Asia/Macao': 'CST-8', + 'Asia/Macau': 'CST-8', + 'Asia/Magadan': '<+11>-11', + 'Asia/Makassar': 'WITA-8', + 'Asia/Manila': 'PST-8', + 'Asia/Muscat': '<+04>-4', + 'Asia/Nicosia': 'EET-2EEST,M3.5.0/3,M10.5.0/4', + 'Asia/Novokuznetsk': '<+07>-7', + 'Asia/Novosibirsk': '<+07>-7', + 'Asia/Omsk': '<+06>-6', + 'Asia/Oral': '<+05>-5', + 'Asia/Phnom_Penh': '<+07>-7', + 'Asia/Pontianak': 'WIB-7', + 'Asia/Pyongyang': 'KST-9', + 'Asia/Qatar': '<+03>-3', + 'Asia/Qostanay': '<+05>-5', + 'Asia/Qyzylorda': '<+05>-5', + 'Asia/Rangoon': '<+0630>-6:30', + 'Asia/Riyadh': '<+03>-3', + 'Asia/Saigon': '<+07>-7', + 'Asia/Sakhalin': '<+11>-11', + 'Asia/Samarkand': '<+05>-5', + 'Asia/Seoul': 'KST-9', + 'Asia/Shanghai': 'CST-8', + 'Asia/Singapore': '<+08>-8', + 'Asia/Srednekolymsk': '<+11>-11', + 'Asia/Taipei': 'CST-8', + 'Asia/Tashkent': '<+05>-5', + 'Asia/Tbilisi': '<+04>-4', + 'Asia/Tehran': '<+0330>-3:30', + 'Asia/Tel_Aviv': 'IST-2IDT,M3.4.4/26,M10.5.0', + 'Asia/Thimbu': '<+06>-6', + 'Asia/Thimphu': '<+06>-6', + 'Asia/Tokyo': 'JST-9', + 'Asia/Tomsk': '<+07>-7', + 'Asia/Ujung_Pandang': 'WITA-8', + 'Asia/Ulaanbaatar': '<+08>-8', + 'Asia/Ulan_Bator': '<+08>-8', + 'Asia/Urumqi': '<+06>-6', + 'Asia/Ust-Nera': '<+10>-10', + 'Asia/Vientiane': '<+07>-7', + 'Asia/Vladivostok': '<+10>-10', + 'Asia/Yakutsk': '<+09>-9', + 'Asia/Yangon': '<+0630>-6:30', + 'Asia/Yekaterinburg': '<+05>-5', + 'Asia/Yerevan': '<+04>-4', + 'Atlantic/Azores': '<-01>1<+00>,M3.5.0/0,M10.5.0/1', + 'Atlantic/Bermuda': 'AST4ADT,M3.2.0,M11.1.0', + 'Atlantic/Canary': 'WET0WEST,M3.5.0/1,M10.5.0', + 'Atlantic/Cape_Verde': '<-01>1', + 'Atlantic/Faeroe': 'WET0WEST,M3.5.0/1,M10.5.0', + 'Atlantic/Faroe': 'WET0WEST,M3.5.0/1,M10.5.0', + 'Atlantic/Jan_Mayen': 'CET-1CEST,M3.5.0,M10.5.0/3', + 'Atlantic/Madeira': 'WET0WEST,M3.5.0/1,M10.5.0', + 'Atlantic/Reykjavik': 'GMT0', + 'Atlantic/South_Georgia': '<-02>2', + 'Atlantic/St_Helena': 'GMT0', + 'Atlantic/Stanley': '<-03>3', + 'Australia/ACT': 'AEST-10AEDT,M10.1.0,M4.1.0/3', + 'Australia/Adelaide': 'ACST-9:30ACDT,M10.1.0,M4.1.0/3', + 'Australia/Brisbane': 'AEST-10', + 'Australia/Broken_Hill': 'ACST-9:30ACDT,M10.1.0,M4.1.0/3', + 'Australia/Canberra': 'AEST-10AEDT,M10.1.0,M4.1.0/3', + 'Australia/Currie': 'AEST-10AEDT,M10.1.0,M4.1.0/3', + 'Australia/Darwin': 'ACST-9:30', + 'Australia/Eucla': '<+0845>-8:45', + 'Australia/Hobart': 'AEST-10AEDT,M10.1.0,M4.1.0/3', + 'Australia/LHI': '<+1030>-10:30<+11>-11,M10.1.0,M4.1.0', + 'Australia/Lindeman': 'AEST-10', + 'Australia/Lord_Howe': '<+1030>-10:30<+11>-11,M10.1.0,M4.1.0', + 'Australia/Melbourne': 'AEST-10AEDT,M10.1.0,M4.1.0/3', + 'Australia/NSW': 'AEST-10AEDT,M10.1.0,M4.1.0/3', + 'Australia/North': 'ACST-9:30', + 'Australia/Perth': 'AWST-8', + 'Australia/Queensland': 'AEST-10', + 'Australia/South': 'ACST-9:30ACDT,M10.1.0,M4.1.0/3', + 'Australia/Sydney': 'AEST-10AEDT,M10.1.0,M4.1.0/3', + 'Australia/Tasmania': 'AEST-10AEDT,M10.1.0,M4.1.0/3', + 'Australia/Victoria': 'AEST-10AEDT,M10.1.0,M4.1.0/3', + 'Australia/West': 'AWST-8', + 'Australia/Yancowinna': 'ACST-9:30ACDT,M10.1.0,M4.1.0/3', + 'Brazil/Acre': '<-05>5', + 'Brazil/DeNoronha': '<-02>2', + 'Brazil/East': '<-03>3', + 'Brazil/West': '<-04>4', + 'CET': 'CET-1CEST,M3.5.0,M10.5.0/3', + 'CST6CDT': 'CST6CDT,M3.2.0,M11.1.0', + 'Canada/Atlantic': 'AST4ADT,M3.2.0,M11.1.0', + 'Canada/Central': 'CST6CDT,M3.2.0,M11.1.0', + 'Canada/Eastern': 'EST5EDT,M3.2.0,M11.1.0', + 'Canada/Mountain': 'MST7MDT,M3.2.0,M11.1.0', + 'Canada/Newfoundland': 'NST3:30NDT,M3.2.0,M11.1.0', + 'Canada/Pacific': 'PST8PDT,M3.2.0,M11.1.0', + 'Canada/Saskatchewan': 'CST6', + 'Canada/Yukon': 'MST7', + 'Chile/Continental': '<-04>4<-03>,M9.1.6/24,M4.1.6/24', + 'Chile/EasterIsland': '<-06>6<-05>,M9.1.6/22,M4.1.6/22', + 'Cuba': 'CST5CDT,M3.2.0/0,M11.1.0/1', + 'EET': 'EET-2EEST,M3.5.0/3,M10.5.0/4', + 'EST': 'EST5', + 'EST5EDT': 'EST5EDT,M3.2.0,M11.1.0', + 'Egypt': 'EET-2EEST,M4.5.5/0,M10.5.4/24', + 'Eire': 'GMT0IST,M3.5.0/1,M10.5.0', + 'Etc/GMT': 'GMT0', + 'Etc/GMT+0': 'GMT0', + 'Etc/GMT+1': '<-01>1', + 'Etc/GMT+10': '<-10>10', + 'Etc/GMT+11': '<-11>11', + 'Etc/GMT+12': '<-12>12', + 'Etc/GMT+2': '<-02>2', + 'Etc/GMT+3': '<-03>3', + 'Etc/GMT+4': '<-04>4', + 'Etc/GMT+5': '<-05>5', + 'Etc/GMT+6': '<-06>6', + 'Etc/GMT+7': '<-07>7', + 'Etc/GMT+8': '<-08>8', + 'Etc/GMT+9': '<-09>9', + 'Etc/GMT-0': 'GMT0', + 'Etc/GMT-1': '<+01>-1', + 'Etc/GMT-10': '<+10>-10', + 'Etc/GMT-11': '<+11>-11', + 'Etc/GMT-12': '<+12>-12', + 'Etc/GMT-13': '<+13>-13', + 'Etc/GMT-14': '<+14>-14', + 'Etc/GMT-2': '<+02>-2', + 'Etc/GMT-3': '<+03>-3', + 'Etc/GMT-4': '<+04>-4', + 'Etc/GMT-5': '<+05>-5', + 'Etc/GMT-6': '<+06>-6', + 'Etc/GMT-7': '<+07>-7', + 'Etc/GMT-8': '<+08>-8', + 'Etc/GMT-9': '<+09>-9', + 'Etc/GMT0': 'GMT0', + 'Etc/Greenwich': 'GMT0', + 'Etc/UCT': 'UTC0', + 'Etc/UTC': 'UTC0', + 'Etc/Universal': 'UTC0', + 'Etc/Zulu': 'UTC0', + 'Europe/Amsterdam': 'CET-1CEST,M3.5.0,M10.5.0/3', + 'Europe/Andorra': 'CET-1CEST,M3.5.0,M10.5.0/3', + 'Europe/Astrakhan': '<+04>-4', + 'Europe/Athens': 'EET-2EEST,M3.5.0/3,M10.5.0/4', + 'Europe/Belfast': 'GMT0BST,M3.5.0/1,M10.5.0', + 'Europe/Belgrade': 'CET-1CEST,M3.5.0,M10.5.0/3', + 'Europe/Berlin': 'CET-1CEST,M3.5.0,M10.5.0/3', + 'Europe/Bratislava': 'CET-1CEST,M3.5.0,M10.5.0/3', + 'Europe/Brussels': 'CET-1CEST,M3.5.0,M10.5.0/3', + 'Europe/Bucharest': 'EET-2EEST,M3.5.0/3,M10.5.0/4', + 'Europe/Budapest': 'CET-1CEST,M3.5.0,M10.5.0/3', + 'Europe/Busingen': 'CET-1CEST,M3.5.0,M10.5.0/3', + 'Europe/Chisinau': 'EET-2EEST,M3.5.0,M10.5.0/3', + 'Europe/Copenhagen': 'CET-1CEST,M3.5.0,M10.5.0/3', + 'Europe/Dublin': 'GMT0IST,M3.5.0/1,M10.5.0', + 'Europe/Gibraltar': 'CET-1CEST,M3.5.0,M10.5.0/3', + 'Europe/Guernsey': 'GMT0BST,M3.5.0/1,M10.5.0', + 'Europe/Helsinki': 'EET-2EEST,M3.5.0/3,M10.5.0/4', + 'Europe/Isle_of_Man': 'GMT0BST,M3.5.0/1,M10.5.0', + 'Europe/Istanbul': '<+03>-3', + 'Europe/Jersey': 'GMT0BST,M3.5.0/1,M10.5.0', + 'Europe/Kaliningrad': 'EET-2', + 'Europe/Kiev': 'EET-2EEST,M3.5.0/3,M10.5.0/4', + 'Europe/Kirov': 'MSK-3', + 'Europe/Kyiv': 'EET-2EEST,M3.5.0/3,M10.5.0/4', + 'Europe/Lisbon': 'WET0WEST,M3.5.0/1,M10.5.0', + 'Europe/Ljubljana': 'CET-1CEST,M3.5.0,M10.5.0/3', + 'Europe/London': 'GMT0BST,M3.5.0/1,M10.5.0', + 'Europe/Luxembourg': 'CET-1CEST,M3.5.0,M10.5.0/3', + 'Europe/Madrid': 'CET-1CEST,M3.5.0,M10.5.0/3', + 'Europe/Malta': 'CET-1CEST,M3.5.0,M10.5.0/3', + 'Europe/Mariehamn': 'EET-2EEST,M3.5.0/3,M10.5.0/4', + 'Europe/Minsk': '<+03>-3', + 'Europe/Monaco': 'CET-1CEST,M3.5.0,M10.5.0/3', + 'Europe/Moscow': 'MSK-3', + 'Europe/Nicosia': 'EET-2EEST,M3.5.0/3,M10.5.0/4', + 'Europe/Oslo': 'CET-1CEST,M3.5.0,M10.5.0/3', + 'Europe/Paris': 'CET-1CEST,M3.5.0,M10.5.0/3', + 'Europe/Podgorica': 'CET-1CEST,M3.5.0,M10.5.0/3', + 'Europe/Prague': 'CET-1CEST,M3.5.0,M10.5.0/3', + 'Europe/Riga': 'EET-2EEST,M3.5.0/3,M10.5.0/4', + 'Europe/Rome': 'CET-1CEST,M3.5.0,M10.5.0/3', + 'Europe/Samara': '<+04>-4', + 'Europe/San_Marino': 'CET-1CEST,M3.5.0,M10.5.0/3', + 'Europe/Sarajevo': 'CET-1CEST,M3.5.0,M10.5.0/3', + 'Europe/Saratov': '<+04>-4', + 'Europe/Simferopol': 'MSK-3', + 'Europe/Skopje': 'CET-1CEST,M3.5.0,M10.5.0/3', + 'Europe/Sofia': 'EET-2EEST,M3.5.0/3,M10.5.0/4', + 'Europe/Stockholm': 'CET-1CEST,M3.5.0,M10.5.0/3', + 'Europe/Tallinn': 'EET-2EEST,M3.5.0/3,M10.5.0/4', + 'Europe/Tirane': 'CET-1CEST,M3.5.0,M10.5.0/3', + 'Europe/Tiraspol': 'EET-2EEST,M3.5.0,M10.5.0/3', + 'Europe/Ulyanovsk': '<+04>-4', + 'Europe/Uzhgorod': 'EET-2EEST,M3.5.0/3,M10.5.0/4', + 'Europe/Vaduz': 'CET-1CEST,M3.5.0,M10.5.0/3', + 'Europe/Vatican': 'CET-1CEST,M3.5.0,M10.5.0/3', + 'Europe/Vienna': 'CET-1CEST,M3.5.0,M10.5.0/3', + 'Europe/Vilnius': 'EET-2EEST,M3.5.0/3,M10.5.0/4', + 'Europe/Volgograd': 'MSK-3', + 'Europe/Warsaw': 'CET-1CEST,M3.5.0,M10.5.0/3', + 'Europe/Zagreb': 'CET-1CEST,M3.5.0,M10.5.0/3', + 'Europe/Zaporozhye': 'EET-2EEST,M3.5.0/3,M10.5.0/4', + 'Europe/Zurich': 'CET-1CEST,M3.5.0,M10.5.0/3', + 'Factory': '<-00>0', + 'GB': 'GMT0BST,M3.5.0/1,M10.5.0', + 'GB-Eire': 'GMT0BST,M3.5.0/1,M10.5.0', + 'GMT': 'GMT0', + 'GMT+0': 'GMT0', + 'GMT-0': 'GMT0', + 'GMT0': 'GMT0', + 'Greenwich': 'GMT0', + 'HST': 'HST10', + 'Hongkong': 'HKT-8', + 'Iceland': 'GMT0', + 'Indian/Antananarivo': 'EAT-3', + 'Indian/Chagos': '<+06>-6', + 'Indian/Christmas': '<+07>-7', + 'Indian/Cocos': '<+0630>-6:30', + 'Indian/Comoro': 'EAT-3', + 'Indian/Kerguelen': '<+05>-5', + 'Indian/Mahe': '<+04>-4', + 'Indian/Maldives': '<+05>-5', + 'Indian/Mauritius': '<+04>-4', + 'Indian/Mayotte': 'EAT-3', + 'Indian/Reunion': '<+04>-4', + 'Iran': '<+0330>-3:30', + 'Israel': 'IST-2IDT,M3.4.4/26,M10.5.0', + 'Jamaica': 'EST5', + 'Japan': 'JST-9', + 'Kwajalein': '<+12>-12', + 'Libya': 'EET-2', + 'MET': 'CET-1CEST,M3.5.0,M10.5.0/3', + 'MST': 'MST7', + 'MST7MDT': 'MST7MDT,M3.2.0,M11.1.0', + 'Mexico/BajaNorte': 'PST8PDT,M3.2.0,M11.1.0', + 'Mexico/BajaSur': 'MST7', + 'Mexico/General': 'CST6', + 'NZ': 'NZST-12NZDT,M9.5.0,M4.1.0/3', + 'NZ-CHAT': '<+1245>-12:45<+1345>,M9.5.0/2:45,M4.1.0/3:45', + 'Navajo': 'MST7MDT,M3.2.0,M11.1.0', + 'PRC': 'CST-8', + 'PST8PDT': 'PST8PDT,M3.2.0,M11.1.0', + 'Pacific/Apia': '<+13>-13', + 'Pacific/Auckland': 'NZST-12NZDT,M9.5.0,M4.1.0/3', + 'Pacific/Bougainville': '<+11>-11', + 'Pacific/Chatham': '<+1245>-12:45<+1345>,M9.5.0/2:45,M4.1.0/3:45', + 'Pacific/Chuuk': '<+10>-10', + 'Pacific/Easter': '<-06>6<-05>,M9.1.6/22,M4.1.6/22', + 'Pacific/Efate': '<+11>-11', + 'Pacific/Enderbury': '<+13>-13', + 'Pacific/Fakaofo': '<+13>-13', + 'Pacific/Fiji': '<+12>-12', + 'Pacific/Funafuti': '<+12>-12', + 'Pacific/Galapagos': '<-06>6', + 'Pacific/Gambier': '<-09>9', + 'Pacific/Guadalcanal': '<+11>-11', + 'Pacific/Guam': 'ChST-10', + 'Pacific/Honolulu': 'HST10', + 'Pacific/Johnston': 'HST10', + 'Pacific/Kanton': '<+13>-13', + 'Pacific/Kiritimati': '<+14>-14', + 'Pacific/Kosrae': '<+11>-11', + 'Pacific/Kwajalein': '<+12>-12', + 'Pacific/Majuro': '<+12>-12', + 'Pacific/Marquesas': '<-0930>9:30', + 'Pacific/Midway': 'SST11', + 'Pacific/Nauru': '<+12>-12', + 'Pacific/Niue': '<-11>11', + 'Pacific/Norfolk': '<+11>-11<+12>,M10.1.0,M4.1.0/3', + 'Pacific/Noumea': '<+11>-11', + 'Pacific/Pago_Pago': 'SST11', + 'Pacific/Palau': '<+09>-9', + 'Pacific/Pitcairn': '<-08>8', + 'Pacific/Pohnpei': '<+11>-11', + 'Pacific/Ponape': '<+11>-11', + 'Pacific/Port_Moresby': '<+10>-10', + 'Pacific/Rarotonga': '<-10>10', + 'Pacific/Saipan': 'ChST-10', + 'Pacific/Samoa': 'SST11', + 'Pacific/Tahiti': '<-10>10', + 'Pacific/Tarawa': '<+12>-12', + 'Pacific/Tongatapu': '<+13>-13', + 'Pacific/Truk': '<+10>-10', + 'Pacific/Wake': '<+12>-12', + 'Pacific/Wallis': '<+12>-12', + 'Pacific/Yap': '<+10>-10', + 'Poland': 'CET-1CEST,M3.5.0,M10.5.0/3', + 'Portugal': 'WET0WEST,M3.5.0/1,M10.5.0', + 'ROC': 'CST-8', + 'ROK': 'KST-9', + 'Singapore': '<+08>-8', + 'Turkey': '<+03>-3', + 'UCT': 'UTC0', + 'US/Alaska': 'AKST9AKDT,M3.2.0,M11.1.0', + 'US/Aleutian': 'HST10HDT,M3.2.0,M11.1.0', + 'US/Arizona': 'MST7', + 'US/Central': 'CST6CDT,M3.2.0,M11.1.0', + 'US/East-Indiana': 'EST5EDT,M3.2.0,M11.1.0', + 'US/Eastern': 'EST5EDT,M3.2.0,M11.1.0', + 'US/Hawaii': 'HST10', + 'US/Indiana-Starke': 'CST6CDT,M3.2.0,M11.1.0', + 'US/Michigan': 'EST5EDT,M3.2.0,M11.1.0', + 'US/Mountain': 'MST7MDT,M3.2.0,M11.1.0', + 'US/Pacific': 'PST8PDT,M3.2.0,M11.1.0', + 'US/Samoa': 'SST11', + 'UTC': 'UTC0', + 'Universal': 'UTC0', + 'W-SU': 'MSK-3', + 'WET': 'WET0WEST,M3.5.0/1,M10.5.0', + 'Zulu': 'UTC0', +}; diff --git a/lib/src/features/settings/presentation/settings_screen.dart b/lib/src/features/settings/presentation/settings_screen.dart index 4b36f309..a4e609c2 100644 --- a/lib/src/features/settings/presentation/settings_screen.dart +++ b/lib/src/features/settings/presentation/settings_screen.dart @@ -38,6 +38,9 @@ import '../../accounts/data/accounts_repository.dart'; import '../../accounts/domain/account_connection_state.dart'; import '../../auth/data/auth_repository.dart'; import '../../calendar/data/calendar_repository.dart'; +import '../../calendar/data/microsoft_shared_calendar_service.dart'; +import '../../calendar/data/cloud_calendar_sharing_service.dart'; +import '../../calendar/presentation/cloud_calendar_sharing_content.dart'; import '../../calendar/presentation/ical_import_flow.dart'; import '../../connectivity/network_connectivity_service.dart'; import '../../diagnostics/presentation/diagnostics_screen.dart'; @@ -155,6 +158,10 @@ class _SettingsScreenState extends ConsumerState { onAddGoogle: () => unawaited(_connectAccount(BusyProvider.google)), onAddMicrosoft: () => unawaited(_connectAccount(BusyProvider.microsoft)), + onOpenSharedCalendar: (account) => + unawaited(_openSharedCalendar(account)), + onManageCalendarSharing: (account) => + unawaited(_manageCalendarSharing(account, calendarSources)), onAddApple: () => unawaited(_connectAccount(BusyProvider.appleICloud)), onAddNextcloud: () => unawaited(_connectAccount(BusyProvider.nextcloud)), @@ -970,6 +977,56 @@ class _SettingsScreenState extends ConsumerState { } } + Future _openSharedCalendar(AccountEntity account) async { + final owner = await showBusyMaxTextPrompt( + context, + title: context.l10n.openSharedCalendar, + label: context.l10n.calendarOwnerEmail, + actionLabel: context.l10n.openSharedCalendar, + ); + if (!mounted || owner == null || owner.trim().isEmpty) return; + try { + final result = await ref + .read(microsoftSharedCalendarServiceProvider) + .openPrimaryCalendar(accountId: account.id, owner: owner); + if (mounted && + result.outcome == + MicrosoftSharedCalendarOpenOutcome.rangeUnavailable) { + _showMessage(context, context.l10n.scheduleRangeIncomplete); + } + } on Object catch (error) { + if (mounted) { + _showMessage(context, context.l10n.calendarUpdateFailed('$error')); + } + } + } + + Future _manageCalendarSharing( + AccountEntity account, + List sources, + ) async { + final eligible = [ + for (final source in sources) + if (source.accountId == account.id && + CloudCalendarSharingService.canManageSource(source)) + source, + ]; + await showBusyMaxModalDialog( + context, + builder: (_) => BusyMaxDialogShell( + title: context.l10n.manageCalendarSharing, + maxWidth: 580, + actions: [ + BusyMaxPushButton.suggested( + onPressed: () => Navigator.pop(context), + child: Text(context.l10n.close), + ), + ], + children: [CloudCalendarSharingContent(sources: eligible)], + ), + ); + } + Future _setCalendarProviderVisibility( CalendarSourceEntity source, bool visible, @@ -1272,6 +1329,8 @@ class _AccountManagementSection extends StatelessWidget { required this.connectingProvider, required this.onAddGoogle, required this.onAddMicrosoft, + required this.onOpenSharedCalendar, + required this.onManageCalendarSharing, required this.onAddApple, required this.onAddNextcloud, required this.onCancelConnection, @@ -1303,6 +1362,8 @@ class _AccountManagementSection extends StatelessWidget { final BusyProvider? connectingProvider; final VoidCallback onAddGoogle; final VoidCallback onAddMicrosoft; + final void Function(AccountEntity account) onOpenSharedCalendar; + final void Function(AccountEntity account) onManageCalendarSharing; final VoidCallback onAddApple; final VoidCallback onAddNextcloud; final VoidCallback onCancelConnection; @@ -1415,6 +1476,34 @@ class _AccountManagementSection extends StatelessWidget { onProviderVisibilityChanged: onCalendarProviderVisibilityChanged, ), + if (account.provider == BusyProvider.microsoft && + account.isSignedIn) + BusyMaxGroupedList( + filled: true, + children: [ + BusyMaxActionRow( + title: context.l10n.openSharedCalendar, + leading: const Icon(YaruIcons.calendar), + onTap: () => onOpenSharedCalendar(account), + ), + ], + ), + if (account.isSignedIn && + calendarSources.any( + (source) => + source.accountId == account.id && + CloudCalendarSharingService.canManageSource(source), + )) + BusyMaxGroupedList( + filled: true, + children: [ + BusyMaxActionRow( + title: context.l10n.manageCalendarSharing, + leading: const Icon(YaruIcons.user), + onTap: () => onManageCalendarSharing(account), + ), + ], + ), if (account.provider == BusyProvider.appleICloud || account.provider == BusyProvider.nextcloud) _DavCollectionsCard( @@ -1641,7 +1730,7 @@ class _CalendarSettingsColumnLabel extends StatelessWidget { } } -class _CalendarSettingsRow extends StatelessWidget { +class _CalendarSettingsRow extends ConsumerWidget { const _CalendarSettingsRow({ required this.source, required this.showsProviderVisibility, @@ -1656,7 +1745,7 @@ class _CalendarSettingsRow extends StatelessWidget { onProviderVisibilityChanged; @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { final l10n = context.l10n; final canChangeProviderVisibility = source.capabilities.canChangeProviderVisibility; @@ -1712,6 +1801,38 @@ class _CalendarSettingsRow extends StatelessWidget { ), ), ), + if (source.provider == BusyProvider.microsoft && + source.capabilities.removalMode == + CalendarRemovalMode.removeFromList) + YaruIconButton( + key: ValueKey('settings-calendar-remove-${source.id}'), + tooltip: l10n.removeFromMyCalendars, + icon: const Icon(YaruIcons.trash), + onPressed: () async { + final confirmed = await showBusyMaxConfirm( + context, + title: l10n.removeFromMyCalendars, + message: l10n.removeOpenedSharedCalendarConfirmation( + source.summary, + ), + confirmLabel: l10n.removeAction, + ); + if (!confirmed || !context.mounted) return; + try { + await ref + .read(calendarRepositoryProvider) + .deleteLocalSource(source.id); + } on Object catch (error) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(l10n.calendarUpdateFailed('$error')), + ), + ); + } + } + }, + ), ], ), ), diff --git a/lib/src/features/sync/calendar_pending_ops_replayer.dart b/lib/src/features/sync/calendar_pending_ops_replayer.dart index 48f518f4..cc581c07 100644 --- a/lib/src/features/sync/calendar_pending_ops_replayer.dart +++ b/lib/src/features/sync/calendar_pending_ops_replayer.dart @@ -15,7 +15,12 @@ import '../../core/http/request_dispatch_exception.dart'; import '../../db/app_database.dart'; import '../../google_calendar/google_calendar_errors.dart'; import '../../google_calendar/google_calendar_mapper.dart'; +import '../calendar/domain/google_status_event.dart'; +import '../../google_calendar/google_calendar_api_client.dart'; import '../../microsoft_calendar/microsoft_calendar_errors.dart'; +import '../../microsoft_calendar/microsoft_calendar_api_client.dart'; +import '../../microsoft_calendar/microsoft_calendar_mapper.dart'; +import '../../microsoft_calendar/microsoft_shared_calendar_address.dart'; import 'package:busymax/src/providers/busy_provider.dart'; import '../calendar/data/calendar_repository.dart'; import '../recurrence/domain/event_recurrence_codec.dart'; @@ -173,6 +178,8 @@ class CalendarPendingOpsReplayer { switch (_operationType(op)) { case 'event.create': await _createEvent(op); + case 'event.importException': + await _importEventException(op); case 'event.patch': await _patchEvent(op); case 'event.delete': @@ -392,6 +399,49 @@ class CalendarPendingOpsReplayer { throw const _PendingOpBlocked(); } + Future _requireSharedEventWriteAllowed( + PendingOp op, { + CalendarEvent? local, + }) async { + final calendarId = op.providerCalendarId; + final address = calendarId == null + ? null + : MicrosoftSharedPrimaryCalendarAddress.parse(calendarId); + if (address == null) return; + final client = _client; + if (client is! MicrosoftCalendarApiClient) { + await _blockOp( + op, + 'shared_calendar_unavailable', + 'Owner-mailbox access is unavailable.', + ); + throw const _PendingOpBlocked(); + } + late final CalendarSourceDto fresh; + try { + fresh = await client.getSharedPrimaryCalendar(address.owner); + } on MicrosoftCalendarApiError catch (error) { + if (error.statusCode != 403 && error.statusCode != 404) rethrow; + await _blockOp( + op, + 'calendar_permission_changed', + 'Owner-mailbox access was revoked.', + ); + throw const _PendingOpBlocked(); + } + await _repository.upsertSource(accountId: _accountId, source: fresh); + if (fresh.readOnly || + (local?.visibility?.toLowerCase() == 'private' && + fresh.rawJson['canViewPrivateItems'] != true)) { + await _blockOp( + op, + 'calendar_permission_changed', + 'This delegated calendar or private event is no longer writable.', + ); + throw const _PendingOpBlocked(); + } + } + Future _googleDataOwnerMatchesAccount(CalendarSource source) async { final owner = source.dataOwner?.trim().toLowerCase(); if (owner == null || owner.isEmpty) return false; @@ -408,24 +458,49 @@ class CalendarPendingOpsReplayer { } Future _createEvent(PendingOp op) async { + await _requireSharedEventWriteAllowed(op); final providerCalendarId = _require(op.providerCalendarId, 'calendarId'); final request = await _eventCreateRequest(op); + final importUid = request[calendarEventImportIcalUidKey]?.toString(); final googleEventId = _client.provider == BusyProvider.google ? request[calendarEventGoogleCreateIdKey]?.toString() : null; late final CalendarEventDto event; try { - event = await _client.createEvent( - calendarId: providerCalendarId, - mutation: _eventMutation( - request, - fallbackTimeZone: await _fallbackTimeZone(op), - providerEventId: googleEventId, - transactionId: request[calendarEventMicrosoftTransactionIdKey] - ?.toString(), - ), - guestUpdatePolicy: _guestUpdatePolicy(request), + final mutation = _eventMutation( + request, + fallbackTimeZone: await _fallbackTimeZone(op), + providerEventId: importUid == null ? googleEventId : null, + transactionId: request[calendarEventMicrosoftTransactionIdKey] + ?.toString(), ); + if (importUid != null && _client.provider == BusyProvider.google) { + final importer = _client; + if (importer is! PrivateCalendarImportClient) { + throw StateError('Google private-copy import is unavailable.'); + } + final privateImporter = importer as PrivateCalendarImportClient; + final existing = await privateImporter.eventsWithICalUid( + calendarId: providerCalendarId, + iCalUid: importUid, + ); + if (existing.length > 1) { + throw StateError('Multiple remote events match the imported UID.'); + } + event = existing.isNotEmpty + ? existing.single + : await privateImporter.importEvent( + calendarId: providerCalendarId, + iCalUid: importUid, + mutation: mutation, + ); + } else { + event = await _client.createEvent( + calendarId: providerCalendarId, + mutation: mutation, + guestUpdatePolicy: _guestUpdatePolicy(request), + ); + } } on GoogleCalendarApiError catch (error) { if (_client.provider != BusyProvider.google || googleEventId == null || @@ -440,8 +515,219 @@ class CalendarPendingOpsReplayer { await _replaceLocalEvent(op, event); } + Future _importEventException(PendingOp op) async { + final calendarId = _require(op.providerCalendarId, 'calendarId'); + final localMaster = await _localEvent(op); + await _requireSharedEventWriteAllowed(op, local: localMaster); + final masterId = await _providerEventId(op, localMaster); + final request = _request(op); + final originalStart = _require( + request['originalStart']?.toString(), + 'originalStart', + ); + final allDayOriginal = + localMaster.allDay && + RegExp(r'^\d{4}-\d{2}-\d{2}$').hasMatch(originalStart); + final originalZone = + allDayOriginal && _client.provider == BusyProvider.microsoft + ? await _importOriginalStartZone(op, localMaster) + : null; + if (_client.provider != BusyProvider.google && + _client.provider != BusyProvider.microsoft) { + await _blockOp( + op, + 'unsupported_import_exception', + 'This provider cannot safely resolve an imported occurrence yet.', + ); + throw const _PendingOpBlocked(); + } + final List instances; + Set cancelledIds = const {}; + if (_client is GoogleCalendarApiClient) { + instances = await _client.findImportEventInstances( + calendarId: calendarId, + recurringEventId: masterId, + originalStart: originalStart, + ); + } else { + final microsoft = _client as MicrosoftCalendarApiClient; + final snapshot = await microsoft.getSeriesExceptionSnapshot( + calendarId: calendarId, + recurringEventId: masterId, + ); + cancelledIds = snapshot.cancelledIds; + final originalInstant = allDayOriginal + ? providerWallTimeToInstant( + DateTime.parse(originalStart), + originalZone, + ) + : DateTime.tryParse(originalStart); + if (originalInstant == null) { + throw StateError('The original start cannot be searched in Graph.'); + } + final nearby = await microsoft.listEventInstances( + calendarId: calendarId, + recurringEventId: masterId, + rangeStart: originalInstant.toUtc().subtract(const Duration(days: 1)), + rangeEnd: originalInstant.toUtc().add(const Duration(days: 2)), + ); + instances = { + for (final item in [...snapshot.exceptions, ...nearby]) + item.providerEventId: item, + }.values.toList(); + } + final matching = instances + .where( + (instance) => _sameImportOriginalStart( + instance.providerOriginalStartKey, + originalStart, + allDayZone: originalZone, + ), + ) + .toList(); + final savedId = request['_importInstanceId']?.toString(); + final savedOccurrenceId = request['_importOccurrenceId']?.toString(); + if (matching.isEmpty && + request['cancelled'] == true && + savedOccurrenceId != null && + cancelledIds.contains(savedOccurrenceId)) { + final savedRaw = request['_importInstanceRaw']; + if (savedRaw is! Map) { + throw StateError('The cancelled occurrence identity was not saved.'); + } + await _upsertImportedCancellation( + calendarId, + Map.from(savedRaw), + ); + return; + } + if (matching.isEmpty && savedId != null) { + try { + matching.add( + await _client.getEvent(calendarId: calendarId, eventId: savedId), + ); + } on MicrosoftCalendarApiError catch (error) { + if (error.statusCode != 404) rethrow; + } + } + if (matching.length != 1) { + throw StateError( + 'The imported occurrence could not be identified uniquely.', + ); + } + final instance = matching.single; + if (savedId == null) { + request['_importInstanceId'] = instance.providerEventId; + request['_importOccurrenceId'] = instance.rawJson['occurrenceId']; + request['_importInstanceRaw'] = instance.rawJson; + await (_database.update(_database.pendingOps) + ..where((row) => row.id.equals(op.id))) + .write(PendingOpsCompanion(requestJson: Value(jsonEncode(request)))); + } + if (request['cancelled'] == true) { + if (!instance.isCancelled) { + await _client.deleteEvent( + calendarId: calendarId, + eventId: instance.providerEventId, + guestUpdatePolicy: CalendarGuestUpdatePolicy.doNotSend, + ifMatch: instance.etagOrChangeKey, + ); + } + await _upsertImportedCancellation(calendarId, instance.rawJson); + return; + } + if (instance.isCancelled) { + throw StateError( + 'The imported occurrence is already cancelled remotely.', + ); + } + final patchRequest = {...request} + ..remove('originalStart') + ..remove('cancelled') + ..remove('_importInstanceId') + ..remove('_importOccurrenceId') + ..remove('_importInstanceRaw'); + final updated = await _client.updateEvent( + calendarId: calendarId, + eventId: instance.providerEventId, + mutation: _eventMutation( + patchRequest, + fallbackTimeZone: await _fallbackTimeZone(op), + ), + guestUpdatePolicy: CalendarGuestUpdatePolicy.doNotSend, + ifMatch: instance.etagOrChangeKey, + ); + await _repository.upsertEvent(accountId: _accountId, event: updated); + } + + Future _upsertImportedCancellation( + String calendarId, + Map raw, + ) async { + final cancelledRaw = {...raw}; + final CalendarEventDto event; + if (_client.provider == BusyProvider.google) { + cancelledRaw['status'] = 'cancelled'; + event = googleCalendarEventFromJson(calendarId, cancelledRaw); + } else { + cancelledRaw['isCancelled'] = true; + event = microsoftCalendarEventFromJson(calendarId, cancelledRaw); + } + await _repository.upsertEvent(accountId: _accountId, event: event); + } + + Future _importOriginalStartZone( + PendingOp op, + CalendarEvent master, + ) async { + final recurrence = master.recurrenceJson; + if (recurrence != null) { + final decoded = jsonDecode(recurrence); + if (decoded is Map && decoded['range'] is Map) { + final range = decoded['range'] as Map; + final zone = range['recurrenceTimeZone']?.toString().trim(); + if (zone != null && zone.isNotEmpty) return zone; + } + } + final raw = _jsonObject(master.rawJson ?? '{}'); + final originalZone = raw['originalStartTimeZone']?.toString().trim(); + if (originalZone != null && originalZone.isNotEmpty) return originalZone; + final zone = await _fallbackTimeZone(op, local: master); + if (zone == null || zone.trim().isEmpty) { + throw StateError('The all-day series timezone is unavailable.'); + } + return zone; + } + + bool _sameImportOriginalStart( + String? remote, + String expected, { + String? allDayZone, + }) { + if (remote == expected) return true; + if (remote == null) return false; + if (expected.length == 10 && allDayZone != null) { + final instant = DateTime.tryParse(remote); + if (instant == null || !instant.isUtc) return false; + final date = providerInstantInTimeZone(instant, allDayZone); + return '${date.year.toString().padLeft(4, '0')}-' + '${date.month.toString().padLeft(2, '0')}-' + '${date.day.toString().padLeft(2, '0')}' == + expected; + } + if (expected.length == 10 || remote.length == 10) { + return false; + } + final remoteInstant = DateTime.tryParse(remote); + final expectedInstant = DateTime.tryParse(expected); + return remoteInstant != null && + expectedInstant != null && + remoteInstant.toUtc().isAtSameMomentAs(expectedInstant.toUtc()); + } + Future> _eventCreateRequest(PendingOp op) async { final request = _request(op); + if (request.containsKey(calendarEventImportIcalUidKey)) return request; final identity = switch (_client.provider) { BusyProvider.google => ( key: calendarEventGoogleCreateIdKey, @@ -469,6 +755,7 @@ class CalendarPendingOpsReplayer { Future _patchEvent(PendingOp op) async { final providerCalendarId = _require(op.providerCalendarId, 'calendarId'); final local = await _localEvent(op); + await _requireSharedEventWriteAllowed(op, local: local); final request = _request(op); final recurringScope = request[calendarEventRecurringScopeKey]?.toString(); final providerEventId = await _providerEventId(op, local); @@ -510,6 +797,30 @@ class CalendarPendingOpsReplayer { ); } } + if (_client.provider == BusyProvider.google && + mutationRequest.containsKey('googleStatusProperties')) { + // Older queued edits may predate the serialized discriminator. Read the + // authoritative event type, but never include eventType in the PATCH. + final current = + checkedVersion ?? + await _client.getEvent( + calendarId: providerCalendarId, + eventId: providerEventId, + ); + final type = current.rawJson['eventType']?.toString(); + if (!googleStatusEventTypes.contains(type)) { + throw StateError('The Google status event type is unavailable.'); + } + final queuedType = + mutationRequest[calendarEventGoogleStatusTypeContextKey]?.toString(); + if (queuedType != null && queuedType != type) { + await _blockConflict(op, 'The Google status event type changed.'); + } + mutationRequest = { + ...mutationRequest, + calendarEventGoogleStatusTypeContextKey: type, + }; + } final event = await _client.updateEvent( calendarId: providerCalendarId, eventId: providerEventId, @@ -640,6 +951,7 @@ class CalendarPendingOpsReplayer { Future _deleteEvent(PendingOp op) async { final providerCalendarId = _require(op.providerCalendarId, 'calendarId'); final local = await _localEvent(op); + await _requireSharedEventWriteAllowed(op, local: local); final request = _request(op); final providerEventId = await _providerEventId(op, local); final checkedVersion = await _ensureEventUnchanged(op, local, 'delete'); @@ -1727,6 +2039,7 @@ class CalendarPendingOpsReplayer { _googleSplitMasterRawKey, _googleSplitFollowingCountKey, _seriesResolvedRequestKey, + calendarEventGoogleStatusTypeContextKey, }; final fields = { for (final entry in request.entries) @@ -1738,6 +2051,10 @@ class CalendarPendingOpsReplayer { (fields.remove('location') | fields.remove('structuredLocation'))) { fields.add(_microsoftLocationStateField); } + if (provider == BusyProvider.google && + fields.contains('googleStatusProperties')) { + fields.add('googleStatusEventType'); + } return fields; } @@ -1774,6 +2091,12 @@ class CalendarPendingOpsReplayer { 'attendeesJson': raw['attendees'], 'attachmentsJson': raw['attachments'], 'colorId': raw['colorId'], + 'eventLabelId': raw['eventLabelId']?.toString() ?? '', + 'googleStatusEventType': raw['eventType']?.toString(), + 'googleStatusProperties': googleStatusPropertiesFromRaw( + raw['eventType']?.toString(), + raw, + ), 'visibility': raw['visibility'], 'transparencyOrShowAs': raw['transparency'], 'conferenceJson': raw['conferenceData'], @@ -1865,6 +2188,13 @@ class CalendarPendingOpsReplayer { attendees: request['attendeesJson'], clearAttendees: clearFields.contains(calendarEventAttendeesField), colorId: request['colorId']?.toString(), + eventLabelId: request['eventLabelId']?.toString(), + eventType: request['eventType']?.toString(), + googleStatusEventTypeContext: + request[calendarEventGoogleStatusTypeContextKey]?.toString(), + googleStatusProperties: request['googleStatusProperties'] is Map + ? Map.from(request['googleStatusProperties'] as Map) + : null, visibility: request['visibility']?.toString() ?? request['sensitivity']?.toString(), diff --git a/lib/src/features/sync/calendar_sync_engine.dart b/lib/src/features/sync/calendar_sync_engine.dart index e1c9be26..9a93d51d 100644 --- a/lib/src/features/sync/calendar_sync_engine.dart +++ b/lib/src/features/sync/calendar_sync_engine.dart @@ -1,6 +1,14 @@ +import 'dart:convert'; + import '../../calendar_providers/cloud_calendar_client.dart'; import '../../calendar_providers/calendar_sync_dto.dart'; import '../../db/app_database.dart'; +import '../../core/time/stored_temporal_projection.dart'; +import '../../google_calendar/google_calendar_errors.dart'; +import '../../microsoft_calendar/microsoft_calendar_errors.dart'; +import '../../microsoft_calendar/microsoft_calendar_api_client.dart'; +import '../../microsoft_calendar/microsoft_shared_calendar_address.dart'; +import 'package:drift/drift.dart'; import 'package:busymax/src/providers/busy_provider.dart'; import '../calendar/data/calendar_repository.dart'; import '../notifications/notification_schedule_service.dart'; @@ -40,6 +48,137 @@ class CalendarSyncEngine { BusyProvider get provider => _client.provider; + /// Retrieves one additional month as an independent snapshot. Range reads + /// must never consume or replace the baseline events cursor. + Future retrieveMonth(DateTime month, {Set? sourceIds}) async { + if (sourceIds != null && sourceIds.isEmpty) return; + final start = DateTime.utc(month.year, month.month); + final end = DateTime.utc(month.year, month.month + 1); + final query = _database.select(_database.calendarSources) + ..where( + (row) => + row.accountId.equals(_accountId) & + row.provider.equals(provider.storageValue) & + row.isDeleted.equals(false), + ); + if (sourceIds == null) { + query.where( + (row) => row.selected.equals(true) & row.hidden.equals(false), + ); + } else { + query.where((row) => row.id.isIn(sourceIds)); + } + final sources = await query.get(); + try { + for (final source in sources) { + // Both clients finish pagination before returning. A failed later page + // therefore cannot turn a partial response into an empty snapshot. + final events = await _client.listEvents( + calendarId: source.providerCalendarId, + rangeStart: start, + rangeEnd: end, + ); + final recurringIds = { + for (final event in events) + if (event.providerRecurringEventId case final id?) id, + }; + final existingInstances = + await (_database.select(_database.calendarEvents)..where( + (row) => + row.calendarSourceId.equals(source.id) & + row.providerRecurringEventId.isNotNull(), + )) + .get(); + recurringIds.addAll( + existingInstances + .where((row) { + return storedCalendarEventOverlapsUtcRange(row, start, end); + }) + .map((row) => row.providerRecurringEventId!), + ); + final instances = []; + final masters = []; + for (final id in recurringIds) { + try { + instances.addAll( + await _client.listEventInstances( + calendarId: source.providerCalendarId, + recurringEventId: id, + rangeStart: start, + rangeEnd: end, + ), + ); + masters.add( + await _client.getEvent( + calendarId: source.providerCalendarId, + eventId: id, + ), + ); + } on GoogleCalendarApiError catch (error) { + if (error.statusCode != 404 && error.statusCode != 410) rethrow; + } on MicrosoftCalendarApiError catch (error) { + if (error.statusCode != 404 && error.statusCode != 410) rethrow; + } + } + final returnedIds = {}; + for (final event in [...events, ...instances]) { + await _repository.upsertEvent( + accountId: _accountId, + event: event, + preservePendingLocalChanges: true, + ); + returnedIds.add( + CalendarRepository.eventId( + accountId: _accountId, + provider: event.provider, + providerCalendarId: event.providerCalendarId, + providerEventId: event.providerEventId, + providerOriginalStartKey: event.providerOriginalStartKey, + ), + ); + } + for (final master in masters) { + await _repository.upsertEvent( + accountId: _accountId, + event: master, + preservePendingLocalChanges: true, + ); + } + await _repository.markExpandedRecurringMastersDeleted( + accountId: _accountId, + provider: provider, + providerCalendarId: source.providerCalendarId, + providerRecurringEventIds: recurringIds, + ); + await _repository.markMissingEventsDeleted( + accountId: _accountId, + provider: provider, + providerCalendarId: source.providerCalendarId, + rangeStart: start, + rangeEnd: end, + returnedLocalEventIds: returnedIds, + ); + await _repository.saveSyncState( + accountId: _accountId, + provider: provider, + syncKind: 'events_range_${start.year}_${start.month}', + calendarSourceId: source.id, + rangeStart: start.toIso8601String(), + rangeEnd: end.toIso8601String(), + cursorKind: 'snapshot_generation', + cursorValue: '0', + full: true, + ); + } + } finally { + await NotificationScheduleService( + database: _database, + nowUtc: _nowUtc, + ).rebuildUpcomingEventNotifications(_accountId); + await _onNotificationScheduleChanged?.call(); + } + } + Future fullSync() async { try { await _replayPendingOps(); @@ -132,6 +271,52 @@ class CalendarSyncEngine { // listCalendars() returns only after every page has been retrieved, so an // absent cloud calendar can be treated as a provider-side deletion. final calendars = await _client.listCalendars(); + if (_client case final MicrosoftCalendarApiClient microsoftClient) { + final opened = + await (_database.select(_database.calendarSources)..where( + (row) => + row.accountId.equals(_accountId) & + row.provider.equals(BusyProvider.microsoft.storageValue), + )) + .get(); + for (final source in opened) { + if (source.isDeleted) continue; + final address = MicrosoftSharedPrimaryCalendarAddress.parse( + source.providerCalendarId, + ); + if (address == null) continue; + try { + final refreshed = await microsoftClient.getSharedPrimaryCalendar( + address.owner, + ); + final current = await (_database.select( + _database.calendarSources, + )..where((row) => row.id.equals(source.id))).getSingleOrNull(); + if (current?.isDeleted == true) continue; + calendars.add(refreshed); + } on MicrosoftCalendarApiError catch (error) { + if (error.statusCode != 403 && error.statusCode != 404) rethrow; + // Explicit owner-context denial is not an empty calendar. Keep its + // cached source, but remove write actions until access is restored. + final oldMetadata = jsonDecode(source.rawJson ?? '{}'); + await (_database.update( + _database.calendarSources, + )..where((row) => row.id.equals(source.id))).write( + CalendarSourcesCompanion( + readOnly: const Value(true), + accessRole: const Value('unavailable'), + rawJson: Value( + jsonEncode({ + if (oldMetadata is Map) + ...oldMetadata.cast(), + '_busymaxOwnerAccessUnavailable': true, + }), + ), + ), + ); + } + } + } for (final calendar in calendars) { await _repository.upsertSource(accountId: _accountId, source: calendar); } @@ -178,6 +363,31 @@ class CalendarSyncEngine { final expandedRecurringMasterIds = {}; while (true) { for (final event in page.events) { + if (provider == BusyProvider.microsoft && event.isDeleted) { + // A calendar-view delta removal describes membership in this view, + // not necessarily deletion of the event in the mailbox. Resolve it + // before touching independently cached ranges. Graph's ID-only + // marker also cannot identify a stored occurrence by itself. + try { + final current = await _client.getEvent( + calendarId: providerCalendarId, + eventId: event.providerEventId, + ); + await _repository.upsertEvent( + accountId: _accountId, + event: current, + preservePendingLocalChanges: true, + ); + } on MicrosoftCalendarApiError catch (error) { + if (error.statusCode != 404 && error.statusCode != 410) rethrow; + await _repository.markMicrosoftRemovedEventDeleted( + accountId: _accountId, + providerCalendarId: providerCalendarId, + providerEventId: event.providerEventId, + ); + } + continue; + } await _repository.upsertEvent( accountId: _accountId, event: event, diff --git a/lib/src/features/sync/cloud_calendar_range_coverage_service.dart b/lib/src/features/sync/cloud_calendar_range_coverage_service.dart new file mode 100644 index 00000000..755af980 --- /dev/null +++ b/lib/src/features/sync/cloud_calendar_range_coverage_service.dart @@ -0,0 +1,134 @@ +import 'dart:async'; + +import 'package:drift/drift.dart'; + +import '../../db/app_database.dart'; +import '../../features/accounts/data/accounts_repository.dart'; +import '../../providers/busy_provider.dart'; +import 'calendar_sync_engine.dart'; + +/// Bounded, independently persisted range snapshots for cloud calendars. +/// Concurrent overlapping views share each source/month request. +final class CloudCalendarRangeCoverageService { + CloudCalendarRangeCoverageService({ + required AppDatabase database, + required CalendarSyncEngine Function(String, BusyProvider) engineForAccount, + DateTime Function()? nowUtc, + }) : _database = database, + _engineForAccount = engineForAccount, + _nowUtc = nowUtc ?? (() => DateTime.now().toUtc()); + + final AppDatabase _database; + final CalendarSyncEngine Function(String, BusyProvider) _engineForAccount; + final DateTime Function() _nowUtc; + final Map> _inFlight = {}; + + /// Returns false if any month could not be verified. Cached events remain + /// available in that case, but a caller must not present an empty range as + /// an authoritative empty result. + Future ensureRange( + DateTime start, + DateTime end, { + Set accountIds = const {}, + Set sourceIds = const {}, + bool sourceFilterActive = false, + }) async { + final from = start.toUtc(); + final until = end.toUtc(); + if (!until.isAfter(from)) return false; + if (sourceFilterActive && sourceIds.isEmpty) return true; + final accountQuery = _database.select(_database.accounts); + if (accountIds.isNotEmpty) { + accountQuery.where((row) => row.id.isIn(accountIds)); + } + final accounts = await accountQuery.get(); + final requested = + < + ({ + String accountId, + BusyProvider provider, + String sourceId, + bool online, + }) + >[]; + for (final account in accounts) { + final provider = BusyProviderCodec.parseStorageValue( + account.provider, + ).provider; + if (provider != BusyProvider.google && + provider != BusyProvider.microsoft) { + continue; + } + final sourceQuery = _database.select(_database.calendarSources) + ..where( + (row) => + row.accountId.equals(account.id) & + row.provider.equals(provider!.storageValue) & + row.isDeleted.equals(false), + ); + if (sourceFilterActive) { + sourceQuery.where((row) => row.id.isIn(sourceIds)); + } else { + sourceQuery.where( + (row) => row.selected.equals(true) & row.hidden.equals(false), + ); + } + for (final source in await sourceQuery.get()) { + requested.add(( + accountId: account.id, + provider: provider!, + sourceId: source.id, + online: account.authState == accountAuthStateSignedIn, + )); + } + } + var complete = true; + for ( + var month = DateTime.utc(from.year, from.month); + month.isBefore(until); + month = DateTime.utc(month.year, month.month + 1) + ) { + final results = await Future.wait([ + for (final source in requested) _ensureSourceMonth(source, month), + ]); + if (results.contains(false)) complete = false; + } + return complete; + } + + Future _ensureSourceMonth( + ({String accountId, BusyProvider provider, String sourceId, bool online}) + source, + DateTime month, + ) async { + final scope = 'events_range_${month.year}_${month.month}'; + Future state() => + (_database.select(_database.syncCursors)..where( + (row) => + row.accountId.equals(source.accountId) & + row.projectionSourceId.equals(source.sourceId) & + row.syncScopeKind.equals(scope), + )) + .getSingleOrNull(); + bool fresh(SyncCursor? cursor) => + cursor?.lastCompleteSyncAt != null && + _nowUtc().millisecondsSinceEpoch - cursor!.lastCompleteSyncAt! < + const Duration(minutes: 5).inMilliseconds; + if (fresh(await state())) return true; + if (!source.online) return false; + final key = + '${source.accountId}|${source.sourceId}|${month.year}|${month.month}'; + final running = _inFlight[key] ??= _engineForAccount( + source.accountId, + source.provider, + ).retrieveMonth(month, sourceIds: {source.sourceId}); + try { + await running; + return fresh(await state()); + } on Object { + return false; + } finally { + if (identical(_inFlight[key], running)) _inFlight.remove(key); + } + } +} diff --git a/lib/src/features/sync/pending_ops_replayer.dart b/lib/src/features/sync/pending_ops_replayer.dart index dec1bf6a..4eb01357 100644 --- a/lib/src/features/sync/pending_ops_replayer.dart +++ b/lib/src/features/sync/pending_ops_replayer.dart @@ -15,6 +15,7 @@ import 'pending_ops_replay_coordinator.dart'; import '../task_lists/data/task_lists_repository.dart'; import '../tasks/data/tasks_repository.dart'; import '../tasks/domain/task_checklist_item.dart'; +import '../tasks/domain/google_task_assignment_policy.dart'; class PendingOpsReplayer { PendingOpsReplayer({ @@ -387,6 +388,21 @@ class PendingOpsReplayer { Future _createTask(PendingOp op) async { final request = _request(op); + if (request['parent'] case final Object parentId) { + final parentPolicy = await _googleAssignment(parentId.toString()); + if (parentPolicy?.isAssigned == true) { + await _blockUnsupportedAssignedTask(op); + } + if (parentPolicy != null) { + final remoteParent = await _apiClient.getTask( + taskListId: op.taskListId!, + taskId: parentId.toString(), + ); + if (_remoteGoogleAssignment(remoteParent).isAssigned) { + await _blockUnsupportedAssignedTask(op); + } + } + } final body = (request['body'] as Map).cast(); final dto = await _apiClient.createTask( taskListId: op.taskListId!, @@ -415,7 +431,27 @@ class PendingOpsReplayer { } Future _patchTask(PendingOp op) async { - await _ensureNoTaskConflict(op, _request(op)); + final request = _request(op); + GoogleTaskAssignmentPolicy? assignment; + if (request.containsKey('notes')) { + assignment = await _googleAssignment(op.taskId!); + if (assignment?.isFromDocument == true && + request['notes']?.toString().isNotEmpty == true) { + await _blockUnsupportedAssignedTask(op); + } + } + final current = await _ensureNoTaskConflict(op, request); + if (assignment != null && request['notes']?.toString().isNotEmpty == true) { + final remote = + current ?? + await _apiClient.getTask( + taskListId: op.taskListId!, + taskId: op.taskId!, + ); + if (_remoteGoogleAssignment(remote).isFromDocument) { + await _blockUnsupportedAssignedTask(op); + } + } final dto = await _apiClient.patchTask( taskListId: op.taskListId!, taskId: op.taskId!, @@ -425,7 +461,27 @@ class PendingOpsReplayer { } Future _updateTask(PendingOp op) async { - await _ensureNoTaskConflict(op, _request(op)); + final request = _request(op); + GoogleTaskAssignmentPolicy? assignment; + if (request.containsKey('notes')) { + assignment = await _googleAssignment(op.taskId!); + if (assignment?.isFromDocument == true && + request['notes']?.toString().isNotEmpty == true) { + await _blockUnsupportedAssignedTask(op); + } + } + final current = await _ensureNoTaskConflict(op, request); + if (assignment != null && request['notes']?.toString().isNotEmpty == true) { + final remote = + current ?? + await _apiClient.getTask( + taskListId: op.taskListId!, + taskId: op.taskId!, + ); + if (_remoteGoogleAssignment(remote).isFromDocument) { + await _blockUnsupportedAssignedTask(op); + } + } final dto = await _apiClient.updateTask( taskListId: op.taskListId!, taskId: op.taskId!, @@ -684,7 +740,19 @@ class PendingOpsReplayer { } Future _deleteTask(PendingOp op) async { - await _ensureTaskUnchanged(op, 'delete'); + final current = await _ensureTaskUnchanged(op, 'delete'); + if (await _googleAssignment(op.taskId!) != null) { + final remote = + current ?? + await _apiClient.getTask( + taskListId: op.taskListId!, + taskId: op.taskId!, + ); + if (_remoteGoogleAssignment(remote).isAssigned && + _request(op)['confirmedAssignedSourceDeletion'] != true) { + await _blockUnsupportedAssignedTask(op); + } + } await _apiClient.deleteTask(taskListId: op.taskListId!, taskId: op.taskId!); await _database.transaction(() async { await _rebasePendingListDeletionChildBaselines(op); @@ -697,10 +765,35 @@ class PendingOpsReplayer { } Future _moveTask(PendingOp op) async { - await _ensureTaskUnchanged(op, 'move'); final request = _request(op); + GoogleTaskAssignmentPolicy? childPolicy; + if (request['parent'] case final Object parentId) { + childPolicy = await _googleAssignment(op.taskId!); + final parentPolicy = await _googleAssignment(parentId.toString()); + if (childPolicy?.isAssigned == true || parentPolicy?.isAssigned == true) { + await _blockUnsupportedAssignedTask(op); + } + } + final current = await _ensureTaskUnchanged(op, 'move'); final destinationTaskListId = request['destinationTasklist']?.toString(); final targetTaskListId = destinationTaskListId ?? op.taskListId!; + final parentId = request['parent']?.toString(); + if (childPolicy != null && parentId != null && parentId.isNotEmpty) { + final remoteChild = + current ?? + await _apiClient.getTask( + taskListId: op.taskListId!, + taskId: op.taskId!, + ); + final remoteParent = await _apiClient.getTask( + taskListId: targetTaskListId, + taskId: parentId, + ); + if (_remoteGoogleAssignment(remoteChild).isAssigned || + _remoteGoogleAssignment(remoteParent).isAssigned) { + await _blockUnsupportedAssignedTask(op); + } + } final dto = await _apiClient.moveTask( sourceTaskListId: op.taskListId!, taskId: op.taskId!, @@ -731,6 +824,38 @@ class PendingOpsReplayer { }); } + Future _googleAssignment(String taskId) async { + final account = await (_database.select( + _database.accounts, + )..where((row) => row.id.equals(_accountId))).getSingleOrNull(); + if (account?.provider != 'google') return null; + final task = + await (_database.select(_database.tasks)..where( + (row) => row.accountId.equals(_accountId) & row.id.equals(taskId), + )) + .getSingleOrNull(); + return task == null + ? const GoogleTaskAssignmentPolicy( + isAssigned: false, + isFromDocument: false, + ) + : GoogleTaskAssignmentPolicy.fromJson(task.assignmentInfoJson); + } + + GoogleTaskAssignmentPolicy _remoteGoogleAssignment(TaskDto task) => + GoogleTaskAssignmentPolicy.fromJson( + task.assignmentInfo == null ? null : jsonEncode(task.assignmentInfo), + ); + + Future _blockUnsupportedAssignedTask(PendingOp op) async { + await _blockOp( + op, + 'unsupported_assigned_task', + 'This operation is not allowed for an assigned Google task.', + ); + throw const _PendingOpBlocked(); + } + Future _preserveDependentMoveProjection( PendingOp op, String targetTaskListId, @@ -1400,7 +1525,7 @@ class PendingOpsReplayer { } } - Future _ensureNoTaskConflict( + Future _ensureNoTaskConflict( PendingOp op, Map pendingFields, ) async { @@ -1408,7 +1533,7 @@ class PendingOpsReplayer { if (baselineUpdatedUtc == null || op.taskListId == null || op.taskId == null) { - return; + return null; } final local = op.baselineRawJson == null @@ -1420,7 +1545,7 @@ class PendingOpsReplayer { )) .getSingleOrNull() : null; - if (op.baselineRawJson == null && local == null) return; + if (op.baselineRawJson == null && local == null) return null; final current = await _apiClient.getTask( taskListId: op.taskListId!, @@ -1446,6 +1571,7 @@ class PendingOpsReplayer { 'Remote task changed fields: ${conflict.changedFields.toList()..sort()}', ); } + return current; } Map _normalizeTaskConflictSnapshot( @@ -1498,12 +1624,12 @@ class PendingOpsReplayer { } } - Future _ensureTaskUnchanged(PendingOp op, String action) async { + Future _ensureTaskUnchanged(PendingOp op, String action) async { final baselineUpdatedUtc = _parseUtc(op.baselineUpdatedUtc); if (baselineUpdatedUtc == null || op.taskListId == null || op.taskId == null) { - return; + return null; } final current = await _apiClient.getTask( @@ -1527,6 +1653,7 @@ class PendingOpsReplayer { 'Remote task changed since local $action was queued.', ); } + return current; } Future _ensureNoTaskInListChangedAfterBaseline( diff --git a/lib/src/features/tasks/data/tasks_repository.dart b/lib/src/features/tasks/data/tasks_repository.dart index 9d1bf223..dbc48b1e 100644 --- a/lib/src/features/tasks/data/tasks_repository.dart +++ b/lib/src/features/tasks/data/tasks_repository.dart @@ -19,6 +19,8 @@ import '../../../google_tasks/api/google_tasks_json.dart'; import '../domain/task_checklist_item.dart'; import '../domain/task_remote_client.dart'; import '../domain/task_remote_models.dart'; +import '../domain/google_task_assignment_policy.dart'; +import '../domain/task_source_links.dart'; import 'package:busymax/src/providers/busy_provider.dart'; import '../../notifications/notification_schedule_service.dart'; @@ -286,6 +288,15 @@ class TaskEntity { final String? linksJson; final String? webViewLink; final String? assignmentInfoJson; + + GoogleTaskAssignmentPolicy get googleAssignment => + GoogleTaskAssignmentPolicy.fromJson(assignmentInfoJson); + + List get googleSourceLinks => googleTaskSourceLinks( + assignmentInfoJson: assignmentInfoJson, + linksJson: linksJson, + webViewLink: webViewLink, + ); } class TaskCreateInput { @@ -573,6 +584,14 @@ class TasksRepository { 'This task collection does not support locations.', ); } + if (input.parentTaskId != null && await _isGoogleAccount()) { + GoogleTaskAssignmentPolicy.fromJson( + (await _requiredTask( + taskListId, + input.parentTaskId!, + )).assignmentInfoJson, + ).checkCanBeParent(); + } final now = _now(); final localId = 'local-task-${_uuid.v4()}'; final fields = input.toFields(); @@ -841,6 +860,11 @@ class TasksRepository { 'This task collection does not support locations.', ); } + if (input.fields.containsKey('notes') && await _isGoogleAccount()) { + GoogleTaskAssignmentPolicy.fromJson( + (await _requiredTask(taskListId, taskId)).assignmentInfoJson, + ).checkNotes(input.fields['notes']); + } final now = _now(); await _database.transaction(() async { final baseline = await _baselineRow(taskListId, taskId); @@ -868,6 +892,11 @@ class TasksRepository { if (taskList.davCollectionId != null) { return _updateDavTaskWithHierarchy(taskList, taskId, input.fields); } + if (input.fields.containsKey('notes') && await _isGoogleAccount()) { + GoogleTaskAssignmentPolicy.fromJson( + (await _requiredTask(taskListId, taskId)).assignmentInfoJson, + ).checkNotes(input.fields['notes']); + } final now = _now(); await _database.transaction(() async { final baseline = await _baselineRow(taskListId, taskId); @@ -886,7 +915,11 @@ class TasksRepository { _onMutationQueued?.call(); } - Future deleteTask(String taskListId, String taskId) async { + Future deleteTask( + String taskListId, + String taskId, { + bool confirmedAssignedSourceDeletion = false, + }) async { final taskList = await _requiredTaskList(taskListId); if (taskList.davCollectionId != null) { await _deleteDavTask(taskList, taskId); @@ -902,6 +935,17 @@ class TasksRepository { await _cancelPendingTaskCreation(pendingCreate); return; } + if (await _isGoogleAccount()) { + final assignment = GoogleTaskAssignmentPolicy.fromJson( + (await _requiredTask(taskListId, taskId)).assignmentInfoJson, + ); + if (assignment.isAssigned && !confirmedAssignedSourceDeletion) { + throw UnsupportedError( + 'Deleting this assigned task also deletes its original task in ' + 'Docs or Chat Spaces. Confirm that consequence first.', + ); + } + } final baseline = await _baselineRow(taskListId, taskId); await _writeLocalTask( taskListId, @@ -916,7 +960,10 @@ class TasksRepository { operation: 'delete_task', taskListId: taskListId, taskId: taskId, - request: const {}, + request: { + if (confirmedAssignedSourceDeletion) + 'confirmedAssignedSourceDeletion': true, + }, baselineUpdatedUtc: baseline?.updatedUtc, baselineRawJson: baseline?.rawJson, createdAtUtc: now, @@ -937,6 +984,22 @@ class TasksRepository { _onMutationQueued?.call(); return; } + if (await _isGoogleAccount()) { + GoogleTaskAssignmentPolicy.fromJson( + (await _requiredTask( + input.sourceTaskListId, + input.taskId, + )).assignmentInfoJson, + ).checkCanBeChild(input.parentTaskId); + if (input.parentTaskId case final parentId?) { + GoogleTaskAssignmentPolicy.fromJson( + (await _requiredTask( + input.destinationTaskListId ?? input.sourceTaskListId, + parentId, + )).assignmentInfoJson, + ).checkCanBeParent(); + } + } final now = _now(); await _database.transaction(() async { final baseline = await _baselineRow(input.sourceTaskListId, input.taskId); @@ -1945,6 +2008,13 @@ class TasksRepository { return taskList; } + Future _isGoogleAccount() async { + final account = await (_database.select( + _database.accounts, + )..where((row) => row.id.equals(_accountId))).getSingleOrNull(); + return account?.provider == BusyProvider.google.storageValue; + } + Future _requiredTask(String taskListId, String taskId) async { final task = await _baselineRow(taskListId, taskId); if (task == null || task.serverMissing || task.pendingDelete) { diff --git a/lib/src/features/tasks/domain/google_task_assignment_policy.dart b/lib/src/features/tasks/domain/google_task_assignment_policy.dart new file mode 100644 index 00000000..0bc04f3c --- /dev/null +++ b/lib/src/features/tasks/domain/google_task_assignment_policy.dart @@ -0,0 +1,72 @@ +import 'dart:convert'; + +/// Restrictions attached to an individual Google task, not to its list. +class GoogleTaskAssignmentPolicy { + const GoogleTaskAssignmentPolicy({ + required this.isAssigned, + required this.isFromDocument, + this.originalTaskUrl, + }); + + factory GoogleTaskAssignmentPolicy.fromJson(String? value) { + if (value == null || value.trim().isEmpty) { + return const GoogleTaskAssignmentPolicy( + isAssigned: false, + isFromDocument: false, + ); + } + Object? decoded; + try { + decoded = jsonDecode(value); + } on FormatException { + // The stored source is present but unreadable. Keep the conservative + // hierarchy restriction until a later sync repairs the metadata. + return const GoogleTaskAssignmentPolicy( + isAssigned: true, + isFromDocument: false, + ); + } + if (decoded is! Map) { + return const GoogleTaskAssignmentPolicy( + isAssigned: true, + isFromDocument: false, + ); + } + final type = decoded['surfaceType']?.toString().toUpperCase(); + final rawUrl = decoded['linkToTask']?.toString(); + final url = Uri.tryParse(rawUrl ?? ''); + return GoogleTaskAssignmentPolicy( + isAssigned: true, + isFromDocument: type == 'DOCUMENT', + originalTaskUrl: + url != null && + (url.scheme == 'https' || url.scheme == 'http') && + url.host.isNotEmpty && + url.userInfo.isEmpty + ? url.toString() + : null, + ); + } + + final bool isAssigned; + final bool isFromDocument; + final String? originalTaskUrl; + + void checkNotes(Object? notes) { + if (isFromDocument && notes != null && notes.toString().isNotEmpty) { + throw UnsupportedError('Tasks assigned from Docs cannot have notes.'); + } + } + + void checkCanBeChild(String? parentId) { + if (isAssigned && parentId != null && parentId.isNotEmpty) { + throw UnsupportedError('Assigned tasks cannot become subtasks.'); + } + } + + void checkCanBeParent() { + if (isAssigned) { + throw UnsupportedError('Assigned tasks cannot have subtasks.'); + } + } +} diff --git a/lib/src/features/tasks/domain/task_source_links.dart b/lib/src/features/tasks/domain/task_source_links.dart new file mode 100644 index 00000000..d1f84d09 --- /dev/null +++ b/lib/src/features/tasks/domain/task_source_links.dart @@ -0,0 +1,61 @@ +import 'dart:convert'; + +/// Provider-supplied task navigation only. IDs are never used to invent URLs. +class TaskSourceLink { + const TaskSourceLink({required this.url, this.label}); + + final String url; + final String? label; +} + +List googleTaskSourceLinks({ + required String? assignmentInfoJson, + required String? linksJson, + required String? webViewLink, +}) { + final result = []; + final seen = {}; + void add(String? url, String? label) { + final uri = Uri.tryParse(url?.trim() ?? ''); + if (uri == null || + (uri.scheme != 'https' && uri.scheme != 'http') || + uri.host.isEmpty || + uri.userInfo.isNotEmpty || + !seen.add(uri.toString())) { + return; + } + result.add(TaskSourceLink(url: uri.toString(), label: label)); + } + + try { + final assignment = jsonDecode(assignmentInfoJson ?? ''); + if (assignment is Map) { + add(assignment['linkToTask']?.toString(), null); + } + } on Object { + // Cached source metadata may be unavailable or malformed; it does not + // affect editing/completion. + } + try { + final links = jsonDecode(linksJson ?? ''); + if (links is List) { + for (final raw in links) { + if (raw is! Map) continue; + final description = raw['description']?.toString().trim(); + final type = raw['type']?.toString().trim(); + add( + raw['link']?.toString(), + description != null && description.isNotEmpty + ? description + : type != null && type.isNotEmpty + ? type + : null, + ); + } + } + } on Object { + // Do not hide an independently valid task page link. + } + add(webViewLink, 'Google Tasks'); + return List.unmodifiable(result); +} diff --git a/lib/src/features/tasks/presentation/new_task_dialog.dart b/lib/src/features/tasks/presentation/new_task_dialog.dart index 3cc98886..4aa98621 100644 --- a/lib/src/features/tasks/presentation/new_task_dialog.dart +++ b/lib/src/features/tasks/presentation/new_task_dialog.dart @@ -144,6 +144,7 @@ class _NewTaskEditorPanelState extends ConsumerState { return TaskDetailsEditor( key: ValueKey('new-task-editor-$accountId-$effectiveListId'), task: editorTask, + provider: provider, taskLists: taskLists, capabilities: capabilities, localTimeZone: localTimeZone, diff --git a/lib/src/features/tasks/presentation/task_details_editor.dart b/lib/src/features/tasks/presentation/task_details_editor.dart index a3e17f9f..0c9488d9 100644 --- a/lib/src/features/tasks/presentation/task_details_editor.dart +++ b/lib/src/features/tasks/presentation/task_details_editor.dart @@ -3,19 +3,30 @@ import 'dart:async'; import 'dart:convert'; import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter/services.dart'; +import 'package:file_selector/file_selector.dart'; import 'package:yaru/yaru.dart'; import '../../../app/busymax_design.dart'; +import '../../../app/app_bootstrap.dart'; import '../../../app/busymax_dialogs.dart'; import '../../../app/busymax_glyphs.dart'; import '../../../google_tasks/api/google_tasks_json.dart'; import '../../../l10n/l10n.dart'; +import '../../../providers/busy_provider.dart'; import '../../maps/domain/location_result.dart'; import '../../maps/application/external_location_launcher.dart'; import 'package:busymax/src/features/tasks/domain/task_capabilities.dart'; import '../../task_lists/data/task_lists_repository.dart'; import '../data/tasks_repository.dart'; +import '../domain/task_source_links.dart'; +import '../../schedule/presentation/schedule_event_details_format.dart'; +import '../../schedule/presentation/attachment_download.dart'; +import '../../schedule/presentation/attachment_upload_coordinator.dart'; +import '../../../microsoft_todo/api/microsoft_todo_api_client.dart'; +import '../../../microsoft_todo/api/microsoft_todo_api_models.dart'; +import '../../../microsoft_calendar/microsoft_calendar_models.dart'; import 'desktop_date_time_fields.dart'; import 'ical_task_fields_editor.dart'; import 'task_details_draft.dart'; @@ -24,6 +35,7 @@ class TaskDetailsEditor extends StatefulWidget { const TaskDetailsEditor({ super.key, required this.task, + this.provider, required this.taskLists, required this.capabilities, required this.localTimeZone, @@ -66,6 +78,7 @@ class TaskDetailsEditor extends StatefulWidget { }); final TaskEntity task; + final BusyProvider? provider; final List taskLists; final TaskCollectionCapabilities capabilities; final String localTimeZone; @@ -132,6 +145,9 @@ class _TaskDetailsEditorState extends State { var _confirmingTaskSwitch = false; var _confirmingDelete = false; var _creatingSubtask = false; + Future>? _linkedResourcesFuture; + Future>? _attachmentsFuture; + bool _attachmentBusy = false; @override void initState() { @@ -239,6 +255,285 @@ class _TaskDetailsEditorState extends State { ), ], ), + if (_editingTask.googleSourceLinks.isNotEmpty) + BusyMaxGroupedList( + filled: true, + children: [ + for (final link in _editingTask.googleSourceLinks) + BusyMaxActionRow( + title: link.label?.isNotEmpty == true + ? link.label! + : l10n.openInProvider, + subtitle: link.url, + leading: const Icon(Icons.open_in_new), + onTap: () => + unawaited(_openOriginalTask(link.url)), + ), + ], + ), + if (widget.provider == BusyProvider.microsoft && + !widget.isCreate) + Consumer( + builder: (context, ref, _) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + BusyMaxPushButton.standard( + onPressed: () => setState(() { + ref.invalidate( + microsoftTaskLinkedResourcesProvider(( + accountId: _editingTask.accountId, + taskListId: _editingTask.taskListId, + taskId: _editingTask.id, + )), + ); + _linkedResourcesFuture = ref.read( + microsoftTaskLinkedResourcesProvider(( + accountId: _editingTask.accountId, + taskListId: _editingTask.taskListId, + taskId: _editingTask.id, + )).future, + ); + }), + child: Text(l10n.linkedResources), + ), + if (_linkedResourcesFuture case final future?) + FutureBuilder>( + future: future, + builder: (context, snapshot) { + if (snapshot.hasError) { + return Text(l10n.operationFailed); + } + if (!snapshot.hasData) { + return const YaruCircularProgressIndicator(); + } + if (snapshot.data!.isEmpty) { + return Text(l10n.noLinkedResources); + } + return BusyMaxGroupedList( + filled: true, + children: [ + for (final link in snapshot.data!) + BusyMaxActionRow( + title: + link.label?.isNotEmpty == true + ? link.label! + : l10n.openInProvider, + subtitle: link.url, + leading: const Icon( + Icons.open_in_new, + ), + onTap: () => unawaited( + _openOriginalTask(link.url), + ), + ), + ], + ); + }, + ), + ], + ); + }, + ), + if (widget.provider == BusyProvider.microsoft && + !widget.isCreate) + Consumer( + builder: (context, ref, _) => Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + BusyMaxPushButton.standard( + key: const Key('task-attachments-load'), + onPressed: () => setState(() { + final key = ( + accountId: _editingTask.accountId, + taskListId: _editingTask.taskListId, + taskId: _editingTask.id, + ); + ref.invalidate( + microsoftTaskAttachmentsProvider(key), + ); + _attachmentsFuture = ref.read( + microsoftTaskAttachmentsProvider(key).future, + ); + }), + child: Text(l10n.attachments), + ), + if (_canWrite && + !_editingTask.pendingDelete && + !_editingTask.id.startsWith('local-task-')) + BusyMaxPushButton.standard( + onPressed: + _attachmentBusy || + !ref + .watch( + attachmentUploadCoordinatorProvider, + ) + .canSubmit( + AttachmentUploadCoordinator.taskKey( + _editingTask.accountId, + _editingTask.taskListId, + _editingTask.id, + ), + ) + ? null + : () => + unawaited(_uploadTaskAttachment(ref)), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.add, size: 18), + Text(l10n.attachments), + ], + ), + ), + if (ref + .watch(attachmentUploadCoordinatorProvider) + .retryAfter( + AttachmentUploadCoordinator.taskKey( + _editingTask.accountId, + _editingTask.taskListId, + _editingTask.id, + ), + ) != + null) + Text(l10n.attachmentUploadRateLimited), + if (ref + .watch(attachmentUploadCoordinatorProvider) + .needsReconciliation( + AttachmentUploadCoordinator.taskKey( + _editingTask.accountId, + _editingTask.taskListId, + _editingTask.id, + ), + )) ...[ + Text( + ref + .watch( + attachmentUploadCoordinatorProvider, + ) + .confirmedId( + AttachmentUploadCoordinator.taskKey( + _editingTask.accountId, + _editingTask.taskListId, + _editingTask.id, + ), + ) == + null + ? l10n.attachmentUploadUnresolved + : l10n.completed, + ), + BusyMaxPushButton.standard( + onPressed: () => + unawaited(_reconcileTaskUpload(ref)), + child: Text(l10n.refresh), + ), + if (ref + .watch(attachmentUploadCoordinatorProvider) + .hasResumableSession( + AttachmentUploadCoordinator.taskKey( + _editingTask.accountId, + _editingTask.taskListId, + _editingTask.id, + ), + )) + BusyMaxPushButton.standard( + onPressed: _attachmentBusy + ? null + : () => unawaited(_cancelTaskUpload(ref)), + child: Text(l10n.cancel), + ), + if (ref + .watch(attachmentUploadCoordinatorProvider) + .canResolveManually( + AttachmentUploadCoordinator.taskKey( + _editingTask.accountId, + _editingTask.taskListId, + _editingTask.id, + ), + )) ...[ + BusyMaxPushButton.standard( + onPressed: _attachmentBusy + ? null + : () => unawaited( + _resolveTaskUpload(ref, exists: true), + ), + child: Text(l10n.completed), + ), + BusyMaxPushButton.standard( + onPressed: _attachmentBusy + ? null + : () => unawaited( + _resolveTaskUpload( + ref, + exists: false, + ), + ), + child: Text(l10n.retry), + ), + ], + ], + if (_attachmentsFuture case final future?) + FutureBuilder>( + future: future, + builder: (context, snapshot) { + if (snapshot.hasError) { + return Text(l10n.attachmentsNotLoaded); + } + if (!snapshot.hasData) { + return const YaruCircularProgressIndicator(); + } + if (snapshot.data!.isEmpty) { + return Text(l10n.noneValue); + } + return BusyMaxGroupedList( + filled: true, + children: [ + for (final attachment in snapshot.data!) + BusyMaxActionRow( + title: attachment.name, + subtitle: attachment.size == null + ? null + : '${attachment.size} B', + leading: const Icon( + Icons.attach_file, + ), + onTap: attachment.isFile + ? () => unawaited( + _downloadTaskAttachment( + ref, + attachment, + ), + ) + : null, + trailing: + _canWrite && + !_editingTask.pendingDelete && + !_editingTask.id.startsWith( + 'local-task-', + ) + ? YaruIconButton( + tooltip: l10n.delete, + onPressed: _attachmentBusy + ? null + : () => unawaited( + _deleteTaskAttachment( + ref, + attachment, + ), + ), + icon: const Icon( + Icons.delete_outline, + ), + ) + : null, + ), + ], + ); + }, + ), + ], + ), + ), if (_hasAccountSelector) BusyMaxGroupedList( filled: true, @@ -308,7 +603,9 @@ class _TaskDetailsEditorState extends State { YaruListTile.square( title: TextField( controller: _notesController, - enabled: _canWrite, + enabled: + _canWrite && + !_editingTask.googleAssignment.isFromDocument, minLines: 3, maxLines: 5, decoration: busyMaxGroupedTextFieldDecoration( @@ -816,7 +1113,7 @@ class _TaskDetailsEditorState extends State { Widget _categoriesRow(TaskDetailsDraft draft) { final l10n = context.l10n; - return BusyMaxCategoryEditorRow( + final editor = BusyMaxCategoryEditorRow( title: l10n.categories, addLabel: l10n.addCategory, categories: draft.categories, @@ -836,6 +1133,78 @@ class _TaskDetailsEditorState extends State { }, onDeleted: (category) => _removeCategory(draft, category), ); + if (widget.provider != BusyProvider.microsoft) return editor; + try { + ProviderScope.containerOf(context, listen: false); + } on StateError { + return editor; + } + return Column( + children: [ + editor, + Consumer( + builder: (context, ref, _) { + final lookup = ref.watch( + microsoftMasterCategoriesProvider(_editingTask.accountId), + ); + return lookup.when( + loading: () => const SizedBox.shrink(), + error: (_, _) => BusyMaxPushButton.standard( + onPressed: () async { + try { + await ref + .read(microsoftCategoryAuthorizationProvider) + ?.authorizeCategoryAccess(_editingTask.accountId); + ref.invalidate( + microsoftMasterCategoriesProvider(_editingTask.accountId), + ); + } on Object { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(l10n.outlookCategoriesUnavailable), + ), + ); + } + } + }, + child: Text(l10n.loadOutlookCategories), + ), + data: (catalog) => Wrap( + spacing: 8, + runSpacing: 8, + children: [ + for (final category in catalog) + BusyMaxPushButton.standard( + onPressed: () => + (_draft ?? draft).categories.contains( + category.displayName, + ) + ? _removeCategory(draft, category.displayName) + : _addCategory(draft, category.displayName), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (microsoftCategorySwatchArgb(category.color) + case final swatch?) ...[ + Container( + width: 12, + height: 12, + color: Color(swatch), + ), + const SizedBox(width: 6), + ], + Text(category.displayName), + ], + ), + ), + ], + ), + ); + }, + ), + ], + ); } void _addCategory(TaskDetailsDraft draft, String value) { @@ -975,8 +1344,14 @@ class _TaskDetailsEditorState extends State { leading: Icon( BusyMaxGlyphs.subdirectoryFor(Directionality.of(context)), ), - enabled: widget.capabilities.canCreateTasks && !_creatingSubtask, - onTap: widget.capabilities.canCreateTasks && !_creatingSubtask + enabled: + widget.capabilities.canCreateTasks && + !_editingTask.googleAssignment.isAssigned && + !_creatingSubtask, + onTap: + widget.capabilities.canCreateTasks && + !_editingTask.googleAssignment.isAssigned && + !_creatingSubtask ? _createSubtask : null, ), @@ -1093,7 +1468,11 @@ class _TaskDetailsEditorState extends State { } Future _createSubtask() async { - if (!widget.capabilities.canCreateTasks || _creatingSubtask) return; + if (!widget.capabilities.canCreateTasks || + _editingTask.googleAssignment.isAssigned || + _creatingSubtask) { + return; + } final title = await showBusyMaxTextPrompt( context, title: context.l10n.newSubtask, @@ -1121,7 +1500,9 @@ class _TaskDetailsEditorState extends State { final confirmed = await showBusyMaxConfirm( context, title: context.l10n.deleteTask, - message: context.l10n.deleteTaskConfirmation(_editingTask.title), + message: _editingTask.googleAssignment.isAssigned + ? '${context.l10n.deleteTaskConfirmation(_editingTask.title)}\n\n${context.l10n.deleteAssignedTaskWarning}' + : context.l10n.deleteTaskConfirmation(_editingTask.title), confirmLabel: context.l10n.delete, destructive: true, barrierColor: widget.dialogBarrierColor, @@ -1134,6 +1515,220 @@ class _TaskDetailsEditorState extends State { } } + Future _openOriginalTask(String url) async { + final opened = await openScheduleWebLink(url); + if (!opened && mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(context.l10n.eventLinkOpenFailed))); + } + } + + Future _downloadTaskAttachment( + WidgetRef ref, + MicrosoftTodoAttachmentDto attachment, + ) async { + if (!attachment.isFile || safeAttachmentFileName(attachment.name) == null) { + return; + } + try { + final client = ref.read( + microsoftTodoApiClientForAccountProvider(_editingTask.accountId), + ); + if (client is! MicrosoftTodoAttachmentsApiClient) { + throw StateError('Microsoft task attachments are unavailable.'); + } + final bytes = await (client as MicrosoftTodoAttachmentsApiClient) + .downloadTaskAttachment( + taskListId: _editingTask.taskListId, + taskId: _editingTask.id, + attachmentId: attachment.id, + ); + await saveAttachmentOnDesktop(name: attachment.name, bytes: bytes); + } on Object catch (error) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.exportFailed('$error'))), + ); + } + } + } + + Future _uploadTaskAttachment(WidgetRef ref) async { + if (!_canWrite || + _editingTask.pendingDelete || + _editingTask.id.startsWith('local-task-')) { + return; + } + final file = await openFile(); + if (file == null || !mounted) return; + final name = safeAttachmentFileName(file.name); + if (name == null) { + _showAttachmentError(const FormatException('Invalid attachment name.')); + return; + } + setState(() => _attachmentBusy = true); + try { + final client = ref.read( + microsoftTodoApiClientForAccountProvider(_editingTask.accountId), + ); + if (client is! MicrosoftTodoAttachmentsApiClient) { + throw StateError('Microsoft task attachments are unavailable.'); + } + final size = await file.length(); + if (size > 25 * 1024 * 1024) { + throw StateError('Task file exceeds 25 MB.'); + } + await ref + .read(attachmentUploadCoordinatorProvider) + .uploadTask( + client: client as MicrosoftTodoAttachmentsApiClient, + accountId: _editingTask.accountId, + taskListId: _editingTask.taskListId, + taskId: _editingTask.id, + name: name, + contentType: file.mimeType ?? 'application/octet-stream', + bytes: await file.readAsBytes(), + ); + _reloadTaskAttachments(ref); + } on Object catch (error) { + _reloadTaskAttachments(ref); + _showAttachmentError(error); + } finally { + if (mounted) setState(() => _attachmentBusy = false); + } + } + + Future _reconcileTaskUpload(WidgetRef ref) async { + setState(() => _attachmentBusy = true); + try { + final client = ref.read( + microsoftTodoApiClientForAccountProvider(_editingTask.accountId), + ); + if (client is! MicrosoftTodoAttachmentsApiClient) return; + await ref + .read(attachmentUploadCoordinatorProvider) + .reconcileTask( + client: client as MicrosoftTodoAttachmentsApiClient, + accountId: _editingTask.accountId, + taskListId: _editingTask.taskListId, + taskId: _editingTask.id, + ); + _reloadTaskAttachments(ref); + } on Object catch (error) { + _showAttachmentError(error); + } finally { + if (mounted) setState(() => _attachmentBusy = false); + } + } + + Future _cancelTaskUpload(WidgetRef ref) async { + setState(() => _attachmentBusy = true); + try { + final client = ref.read( + microsoftTodoApiClientForAccountProvider(_editingTask.accountId), + ); + if (client is! MicrosoftTodoAttachmentsApiClient) return; + await ref + .read(attachmentUploadCoordinatorProvider) + .cancelTask( + client: client as MicrosoftTodoAttachmentsApiClient, + accountId: _editingTask.accountId, + taskListId: _editingTask.taskListId, + taskId: _editingTask.id, + ); + _reloadTaskAttachments(ref); + } on Object catch (error) { + _showAttachmentError(error); + } finally { + if (mounted) setState(() => _attachmentBusy = false); + } + } + + Future _resolveTaskUpload(WidgetRef ref, {required bool exists}) async { + final l10n = context.l10n; + final confirmed = await showBusyMaxConfirm( + context, + title: exists ? l10n.completed : l10n.retry, + message: l10n.attachmentUploadUnresolved, + confirmLabel: exists ? l10n.completed : l10n.retry, + ); + if (confirmed != true || !mounted) return; + ref + .read(attachmentUploadCoordinatorProvider) + .resolveUncertainManually( + AttachmentUploadCoordinator.taskKey( + _editingTask.accountId, + _editingTask.taskListId, + _editingTask.id, + ), + exists: exists, + ); + _reloadTaskAttachments(ref); + } + + Future _deleteTaskAttachment( + WidgetRef ref, + MicrosoftTodoAttachmentDto attachment, + ) async { + if (!_canWrite || + _editingTask.pendingDelete || + _editingTask.id.startsWith('local-task-')) { + return; + } + setState(() => _attachmentBusy = true); + try { + final client = ref.read( + microsoftTodoApiClientForAccountProvider(_editingTask.accountId), + ); + if (client is! MicrosoftTodoAttachmentsApiClient) { + throw StateError('Microsoft task attachments are unavailable.'); + } + await (client as MicrosoftTodoAttachmentsApiClient).deleteTaskAttachment( + taskListId: _editingTask.taskListId, + taskId: _editingTask.id, + attachmentId: attachment.id, + ); + ref + .read(attachmentUploadCoordinatorProvider) + .attachmentRemoved( + AttachmentUploadCoordinator.taskKey( + _editingTask.accountId, + _editingTask.taskListId, + _editingTask.id, + ), + attachment.id, + ); + _reloadTaskAttachments(ref); + } on Object catch (error) { + _showAttachmentError(error); + } finally { + if (mounted) setState(() => _attachmentBusy = false); + } + } + + void _reloadTaskAttachments(WidgetRef ref) { + final key = ( + accountId: _editingTask.accountId, + taskListId: _editingTask.taskListId, + taskId: _editingTask.id, + ); + ref.invalidate(microsoftTaskAttachmentsProvider(key)); + final future = ref.read(microsoftTaskAttachmentsProvider(key).future); + if (mounted) { + setState(() { + _attachmentsFuture = future; + }); + } + } + + void _showAttachmentError(Object error) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.exportFailed('$error'))), + ); + } + void _loadDraft(TaskEntity task, {required bool force}) { final taskKey = _taskKey(task); if (!force && _loadedTaskKey == taskKey) { @@ -1143,6 +1738,8 @@ class _TaskDetailsEditorState extends State { widget.initialDraft ?? TaskDetailsDraft.fromTask(task, widget.localTimeZone); _editingTask = task; + _linkedResourcesFuture = null; + _attachmentsFuture = null; _loadedTaskKey = taskKey; _invalidTimeFields.clear(); _draft = draft; diff --git a/lib/src/features/tasks/presentation/task_details_pane.dart b/lib/src/features/tasks/presentation/task_details_pane.dart index fa4f99e7..d8101f05 100644 --- a/lib/src/features/tasks/presentation/task_details_pane.dart +++ b/lib/src/features/tasks/presentation/task_details_pane.dart @@ -395,6 +395,7 @@ class _TaskDetailsPaneState extends ConsumerState { }) { return TaskDetailsEditor( task: task, + provider: account.provider, taskLists: taskLists, capabilities: capabilities, localTimeZone: localTimeZone, @@ -418,7 +419,11 @@ class _TaskDetailsPaneState extends ConsumerState { onDuplicate: () => _duplicateTask(repository, task), onExport: () => _exportTask(repository, task), onDelete: () async { - await repository.deleteTask(task.taskListId, task.id); + await repository.deleteTask( + task.taskListId, + task.id, + confirmedAssignedSourceDeletion: task.googleAssignment.isAssigned, + ); await widget.onTaskMutationCommitted?.call( TaskMutationResult( kind: TaskMutationKind.deleted, diff --git a/lib/src/google_calendar/google_calendar_api_client.dart b/lib/src/google_calendar/google_calendar_api_client.dart index d0fc2f59..7b5536d8 100644 --- a/lib/src/google_calendar/google_calendar_api_client.dart +++ b/lib/src/google_calendar/google_calendar_api_client.dart @@ -6,6 +6,7 @@ import '../calendar_providers/calendar_mutation.dart'; import '../calendar_providers/calendar_provider_capabilities.dart'; import '../calendar_providers/calendar_sync_dto.dart'; import '../calendar_providers/cloud_calendar_client.dart'; +import '../core/http/request_dispatch_exception.dart'; import 'package:busymax/src/providers/busy_provider.dart'; import 'google_calendar_errors.dart'; import 'google_calendar_mapper.dart'; @@ -15,6 +16,7 @@ class GoogleCalendarApiClient implements CloudCalendarClient, CompleteRecurringInstanceClient, + PrivateCalendarImportClient, DetailedFreeBusyClient, CalendarListManagementClient { GoogleCalendarApiClient({ @@ -69,6 +71,106 @@ class GoogleCalendarApiClient ); } + /// Labels belong to one calendar; never cache these against an account or + /// reuse an ID after the editor changes calendars. + Future> getEventLabels(String calendarId) async { + final json = await _requestJson( + 'GET', + _uri('/calendar/v3/calendars/${_enc(calendarId)}'), + ); + final properties = json['labelProperties']; + if (properties == null) { + throw const FormatException('Calendar label metadata is unavailable.'); + } + if (properties is! Map || properties['eventLabels'] is! List) { + throw const FormatException('Malformed calendar label properties.'); + } + final entries = properties['eventLabels'] as List; + if (entries.any((entry) => entry is! Map)) { + throw const FormatException('Malformed calendar event labels.'); + } + return List.unmodifiable([ + for (final entry in entries) + GoogleEventLabel.fromJson(Map.from(entry as Map)), + ]); + } + + Future> listAclRules(String calendarId) async { + final path = '/calendar/v3/calendars/${_enc(calendarId)}/acl'; + final rules = []; + final seenTokens = {}; + String? pageToken; + do { + final json = await _requestJson( + 'GET', + _uri(path, query: _compactQuery({'pageToken': pageToken})), + ); + final items = json['items']; + if (items is! List || items.any((item) => item is! Map)) { + throw const FormatException('Malformed Google calendar ACL list.'); + } + for (final item in items) { + rules.add( + GoogleAclRule.fromJson(Map.from(item as Map)), + ); + } + pageToken = json['nextPageToken']?.toString(); + if (pageToken != null && + pageToken.isNotEmpty && + !seenTokens.add(pageToken)) { + throw const FormatException('Repeated Google calendar ACL page.'); + } + } while (pageToken != null && pageToken.isNotEmpty); + return List.unmodifiable(rules); + } + + Future addAclUser( + String calendarId, { + required String email, + required String role, + }) async { + _validateAclUserRole(role); + if (!RegExp(r'^[^\s@/]+@[^\s@/]+\.[^\s@/]+$').hasMatch(email)) { + throw ArgumentError.value(email, 'email', 'Invalid recipient address'); + } + final json = await _requestJson( + 'POST', + _uri('/calendar/v3/calendars/${_enc(calendarId)}/acl'), + body: { + 'role': role, + 'scope': {'type': 'user', 'value': email}, + }, + ); + return GoogleAclRule.fromJson(json); + } + + Future changeAclRole( + String calendarId, + GoogleAclRule rule, + String role, + ) async { + _validateAclUserRole(role); + if (rule.role == 'owner' || rule.scopeType != 'user') { + throw ArgumentError('This ACL rule is not mutable here.'); + } + final json = await _requestJson( + 'PATCH', + _uri('/calendar/v3/calendars/${_enc(calendarId)}/acl/${_enc(rule.id)}'), + body: {'role': role}, + ); + return GoogleAclRule.fromJson(json); + } + + Future revokeAclUser(String calendarId, GoogleAclRule rule) { + if (rule.role == 'owner' || rule.scopeType != 'user') { + throw ArgumentError('This ACL rule is not revocable here.'); + } + return _requestEmpty( + 'DELETE', + _uri('/calendar/v3/calendars/${_enc(calendarId)}/acl/${_enc(rule.id)}'), + ); + } + @override Future createCalendar(CalendarMutation mutation) async { final json = await _requestJson( @@ -206,6 +308,7 @@ class GoogleCalendarApiClient CalendarGuestUpdatePolicy guestUpdatePolicy = CalendarGuestUpdatePolicy.send, }) async { + final body = googleEventMutationToJson(mutation); final json = await _requestJson( 'POST', _uri( @@ -213,14 +316,78 @@ class GoogleCalendarApiClient query: { 'conferenceDataVersion': '1', 'supportsAttachments': 'true', + if (body.containsKey('eventLabelId')) 'eventLabelVersion': '1', 'sendUpdates': _googleGuestUpdatePolicy(guestUpdatePolicy), }, ), - body: googleEventMutationToJson(mutation), + body: body, ); return googleCalendarEventFromJson(calendarId, json); } + /// Calendar's import operation creates a private copy identified by the + /// iCalendar UID; it is not an ordinary event insertion or invitation send. + @override + Future importEvent({ + required String calendarId, + required String iCalUid, + required CalendarEventMutation mutation, + }) async { + if (iCalUid.trim().isEmpty) { + throw const FormatException('iCalendar UID is required for import.'); + } + final body = googleEventMutationToJson(mutation) + ..remove('id') + ..['iCalUID'] = iCalUid.trim(); + final json = await _requestJson( + 'POST', + _uri( + '/calendar/v3/calendars/${_enc(calendarId)}/events/import', + query: { + 'supportsAttachments': 'true', + if (body.containsKey('eventLabelId')) 'eventLabelVersion': '1', + }, + ), + body: body, + ); + return googleCalendarEventFromJson(calendarId, json); + } + + @override + Future> eventsWithICalUid({ + required String calendarId, + required String iCalUid, + bool includeCancelled = false, + }) async { + final events = []; + String? pageToken; + final visited = {}; + do { + if (pageToken != null && !visited.add(pageToken)) { + throw const FormatException('Google series pagination loop.'); + } + final json = await _requestJson( + 'GET', + _uri( + '/calendar/v3/calendars/${_enc(calendarId)}/events', + query: _compactQuery({ + 'iCalUID': iCalUid, + 'singleEvents': 'false', + 'showDeleted': includeCancelled ? 'true' : 'false', + 'maxResults': '250', + 'pageToken': pageToken, + }), + ), + ); + final page = GoogleCalendarPage.fromJson(json); + events.addAll( + page.items.map((item) => googleCalendarEventFromJson(calendarId, item)), + ); + pageToken = page.nextPageToken; + } while (pageToken != null && pageToken.isNotEmpty); + return events; + } + @override Future getEvent({ required String calendarId, @@ -235,6 +402,96 @@ class GoogleCalendarApiClient return googleCalendarEventFromJson(calendarId, json); } + /// Changes only the attachment collection, starting from a fresh complete + /// event resource. Google's array patch semantics replace the entire array. + Future changeEventAttachmentReferences({ + required String calendarId, + required String eventId, + String? addFileUrl, + String? addTitle, + String? removeFileUrl, + }) async { + if ((addFileUrl == null) == (removeFileUrl == null)) { + throw ArgumentError('Specify exactly one attachment change.'); + } + final candidate = addFileUrl ?? removeFileUrl!; + final uri = Uri.tryParse(candidate.trim()); + if (uri == null || + uri.scheme != 'https' || + uri.host.isEmpty || + uri.userInfo.isNotEmpty) { + throw const FormatException('Attachment URL must be a valid HTTPS URL.'); + } + final eventUri = _uri( + '/calendar/v3/calendars/${_enc(calendarId)}/events/${_enc(eventId)}', + ); + final current = await _requestJson('GET', eventUri); + final etag = current['etag']?.toString(); + if (etag == null || etag.isEmpty) { + throw const FormatException('Event is missing a conflict token.'); + } + final raw = current['attachments']; + if (raw != null && (raw is! List || raw.any((entry) => entry is! Map))) { + throw const FormatException('Malformed event attachment collection.'); + } + final attachments = >[ + for (final entry in raw is List ? raw : const []) + Map.from(entry as Map), + ]; + if (addFileUrl != null) { + if (attachments.any((entry) => entry['fileUrl'] == uri.toString())) { + return googleCalendarEventFromJson(calendarId, current); + } + if (attachments.length >= 25) { + throw StateError('Google events allow at most 25 attachments.'); + } + attachments.add({ + 'fileUrl': uri.toString(), + if (addTitle != null && addTitle.trim().isNotEmpty) + 'title': addTitle.trim(), + }); + } else { + final before = attachments.length; + attachments.removeWhere((entry) => entry['fileUrl'] == uri.toString()); + if (attachments.length == before) { + return googleCalendarEventFromJson(calendarId, current); + } + } + late final Map updated; + try { + updated = await _requestJson( + 'PATCH', + eventUri.replace( + queryParameters: { + 'supportsAttachments': 'true', + 'sendUpdates': 'none', + }, + ), + body: {'attachments': attachments}, + headers: {'If-Match': etag}, + ); + } on Object catch (error, stack) { + // A failed response can follow a committed write. Read the authoritative + // resource before allowing an operator to retry the array replacement. + try { + final after = await _requestJson('GET', eventUri); + final afterRaw = after['attachments']; + if (afterRaw is List && afterRaw.every((entry) => entry is Map)) { + final present = afterRaw.any( + (entry) => (entry as Map)['fileUrl'] == uri.toString(), + ); + if (present == (addFileUrl != null)) { + return googleCalendarEventFromJson(calendarId, after); + } + } + } on Object { + // Preserve the original failure if reconciliation is unavailable. + } + Error.throwWithStackTrace(error, stack); + } + return googleCalendarEventFromJson(calendarId, updated); + } + @override Future updateEvent({ required String calendarId, @@ -244,16 +501,22 @@ class GoogleCalendarApiClient CalendarGuestUpdatePolicy.send, String? ifMatch, }) async { + // A normal event edit is a PATCH. Sending the cached attachment array + // would replace the provider's entire collection, including references + // added after our last sync. The dedicated attachment operation reads the + // fresh event and uses its ETag before replacing that array. + final body = googleEventMutationToJson(mutation)..remove('attachments'); final json = await _requestJson( 'PATCH', _uri( '/calendar/v3/calendars/${_enc(calendarId)}/events/${_enc(eventId)}', query: { 'conferenceDataVersion': '1', + if (body.containsKey('eventLabelId')) 'eventLabelVersion': '1', 'sendUpdates': _googleGuestUpdatePolicy(guestUpdatePolicy), }, ), - body: googleEventMutationToJson(mutation), + body: body, headers: {if (ifMatch != null) 'If-Match': ifMatch}, ); return googleCalendarEventFromJson(calendarId, json); @@ -266,16 +529,18 @@ class GoogleCalendarApiClient CalendarGuestUpdatePolicy guestUpdatePolicy = CalendarGuestUpdatePolicy.send, }) async { + final body = googleEventMutationToJson(mutation); final json = await _requestJson( 'PUT', _uri( '/calendar/v3/calendars/${_enc(calendarId)}/events/${_enc(eventId)}', query: { 'conferenceDataVersion': '1', + if (body.containsKey('eventLabelId')) 'eventLabelVersion': '1', 'sendUpdates': _googleGuestUpdatePolicy(guestUpdatePolicy), }, ), - body: googleEventMutationToJson(mutation), + body: body, ); return googleCalendarEventFromJson(calendarId, json); } @@ -355,11 +620,24 @@ class GoogleCalendarApiClient recurringEventId: recurringEventId, ); + /// Resolves an imported detached occurrence by its original (not moved) + /// start. Keep complete-series enumeration for workflows that need it. + Future> findImportEventInstances({ + required String calendarId, + required String recurringEventId, + required String originalStart, + }) => _listEventInstances( + calendarId: calendarId, + recurringEventId: recurringEventId, + originalStart: originalStart, + ); + Future> _listEventInstances({ required String calendarId, required String recurringEventId, DateTime? rangeStart, DateTime? rangeEnd, + String? originalStart, }) async { assert((rangeStart == null) == (rangeEnd == null)); final events = []; @@ -373,6 +651,7 @@ class GoogleCalendarApiClient query: _compactQuery({ 'timeMin': rangeStart == null ? null : _rfc3339(rangeStart), 'timeMax': rangeEnd == null ? null : _rfc3339(rangeEnd), + 'originalStart': originalStart, 'showDeleted': 'true', 'pageToken': pageToken, }), @@ -650,11 +929,22 @@ class GoogleCalendarApiClient bool retried = false, }) async { final authorizationHeaderProvider = _authorizationHeaderProvider; + String? authorizationHeader; + try { + authorizationHeader = await authorizationHeaderProvider?.call(); + } on Object catch (error, stackTrace) { + Error.throwWithStackTrace( + KnownUnsentRequestException( + kind: RequestPreDispatchFailureKind.authentication, + cause: error, + ), + stackTrace, + ); + } final requestHeaders = { 'Accept': 'application/json', if (body != null) 'Content-Type': 'application/json', - if (authorizationHeaderProvider != null) - 'Authorization': await authorizationHeaderProvider(), + if (authorizationHeader != null) 'Authorization': authorizationHeader, ...headers, }; final encodedBody = body == null ? null : jsonEncode(body); @@ -682,7 +972,17 @@ class GoogleCalendarApiClient if (response.statusCode == 401 && !retried && unauthorizedRefreshProvider != null) { - await unauthorizedRefreshProvider(); + try { + await unauthorizedRefreshProvider(); + } on Object catch (error, stackTrace) { + Error.throwWithStackTrace( + KnownUnsentRequestException( + kind: RequestPreDispatchFailureKind.authentication, + cause: error, + ), + stackTrace, + ); + } return _send(method, uri, body: body, headers: headers, retried: true); } return response; @@ -707,6 +1007,17 @@ bool _hasCalendarListColor(CalendarMutation mutation) { String _enc(String value) => Uri.encodeComponent(value); +void _validateAclUserRole(String role) { + if (!const { + 'freeBusyReader', + 'reader', + 'writerWithoutPrivateAccess', + 'writer', + }.contains(role)) { + throw ArgumentError.value(role, 'role', 'Unsupported sharing role'); + } +} + String _rfc3339(DateTime value) => value.toUtc().toIso8601String(); Map _compactQuery(Map values) { diff --git a/lib/src/google_calendar/google_calendar_errors.dart b/lib/src/google_calendar/google_calendar_errors.dart index 2dfd4b19..39ea80b7 100644 --- a/lib/src/google_calendar/google_calendar_errors.dart +++ b/lib/src/google_calendar/google_calendar_errors.dart @@ -2,16 +2,21 @@ import 'dart:convert'; import 'package:http/http.dart' as http; +import '../core/http/retry_after.dart'; + class GoogleCalendarApiError implements Exception { const GoogleCalendarApiError({ required this.statusCode, required this.code, required this.message, + this.reasons = const [], + this.retryAfter, }); factory GoogleCalendarApiError.fromResponse(http.Response response) { var code = 'GoogleCalendarApiError'; var message = 'Google Calendar request failed with ${response.statusCode}.'; + final reasons = []; try { final decoded = jsonDecode(response.body); if (decoded is Map) { @@ -19,6 +24,14 @@ class GoogleCalendarApiError implements Exception { if (error is Map) { code = error['status']?.toString() ?? code; message = error['message']?.toString() ?? message; + final entries = error['errors']; + if (entries is List) { + for (final entry in entries) { + if (entry is Map && entry['reason'] is String) { + reasons.add(entry['reason'] as String); + } + } + } } } } on Object { @@ -28,12 +41,25 @@ class GoogleCalendarApiError implements Exception { statusCode: response.statusCode, code: code, message: message, + reasons: List.unmodifiable(reasons), + retryAfter: parseHttpRetryAfter(response.headers['retry-after']), ); } final int statusCode; final String code; final String message; + final List reasons; + final Duration? retryAfter; + + bool get isRateLimited => + statusCode == 429 || + statusCode == 403 && + reasons.any( + (reason) => + reason == 'rateLimitExceeded' || + reason == 'userRateLimitExceeded', + ); bool get isInvalidSyncToken => statusCode == 410; diff --git a/lib/src/google_calendar/google_calendar_mapper.dart b/lib/src/google_calendar/google_calendar_mapper.dart index 3f3659ca..95a2f732 100644 --- a/lib/src/google_calendar/google_calendar_mapper.dart +++ b/lib/src/google_calendar/google_calendar_mapper.dart @@ -74,7 +74,9 @@ CalendarEventDto googleCalendarEventFromJson( eventType: json['eventType']?.toString(), webLink: json['htmlLink']?.toString(), conferenceJson: json['conferenceData'], - attachmentsJson: json['attachments'], + attachmentsJson: json['attachments'] is List + ? json['attachments'] + : const [], isCancelled: status == 'cancelled', isDeleted: status == 'cancelled', createdAtServer: json['created']?.toString(), @@ -114,6 +116,18 @@ Map googleCalendarListColorMutationToJson( } Map googleEventMutationToJson(CalendarEventMutation mutation) { + final statusType = + mutation.eventType ?? + mutation.googleStatusEventTypeContext ?? + mutation.providerRaw?['eventType']?.toString(); + if (mutation.googleStatusProperties != null && + !const { + 'focusTime', + 'outOfOffice', + 'workingLocation', + }.contains(statusType)) { + throw const FormatException('Google status event type is unavailable.'); + } final allDay = mutation.allDay ?? mutation.startDate != null; final start = allDay ? _compact({'date': mutation.startDate}) @@ -145,6 +159,14 @@ Map googleEventMutationToJson(CalendarEventMutation mutation) { ? const [] : mutation.attendees, 'colorId': mutation.colorId, + if (mutation.eventLabelId != null) 'eventLabelId': mutation.eventLabelId, + 'eventType': mutation.eventType, + if (statusType case final type? + when mutation.googleStatusProperties != null) + if (type == 'focusTime' || + type == 'outOfOffice' || + type == 'workingLocation') + '${type}Properties': mutation.googleStatusProperties, 'visibility': mutation.visibility, 'transparency': mutation.transparencyOrShowAs, if (mutation.hideAttendees != null) @@ -167,6 +189,7 @@ Map _googleWritableEventFields(Map? source) { 'reminders', 'attachments', 'colorId', + 'eventLabelId', 'visibility', 'transparency', 'guestsCanInviteOthers', @@ -175,6 +198,10 @@ Map _googleWritableEventFields(Map? source) { 'extendedProperties', 'source', 'conferenceData', + 'eventType', + 'focusTimeProperties', + 'outOfOfficeProperties', + 'workingLocationProperties', }; return { for (final entry in source.entries) diff --git a/lib/src/google_calendar/google_calendar_models.dart b/lib/src/google_calendar/google_calendar_models.dart index 9ad2882c..7d7ea44c 100644 --- a/lib/src/google_calendar/google_calendar_models.dart +++ b/lib/src/google_calendar/google_calendar_models.dart @@ -28,6 +28,68 @@ class GoogleColorsDto { final Map rawJson; } +final class GoogleEventLabel { + const GoogleEventLabel({ + required this.id, + required this.name, + required this.backgroundColor, + }); + + factory GoogleEventLabel.fromJson(Map json) { + final id = json['id']; + final name = json['name']; + final color = json['backgroundColor']; + if (id is! String || + id.isEmpty || + name is! String || + name.isEmpty || + color is! String || + !RegExp(r'^#[0-9a-fA-F]{6}$').hasMatch(color)) { + throw const FormatException('Malformed Google event label.'); + } + return GoogleEventLabel(id: id, name: name, backgroundColor: color); + } + + final String id; + final String name; + final String backgroundColor; +} + +final class GoogleAclRule { + const GoogleAclRule({ + required this.id, + required this.role, + required this.scopeType, + this.scopeValue, + }); + + factory GoogleAclRule.fromJson(Map json) { + final id = json['id']; + final role = json['role']; + final scope = json['scope']; + if (id is! String || + id.isEmpty || + role is! String || + role.isEmpty || + scope is! Map || + scope['type'] is! String || + (scope['type'] as String).isEmpty) { + throw const FormatException('Malformed Google calendar ACL rule.'); + } + return GoogleAclRule( + id: id, + role: role, + scopeType: scope['type'] as String, + scopeValue: scope['value']?.toString(), + ); + } + + final String id; + final String role; + final String scopeType; + final String? scopeValue; +} + List> _mapItems(Map json) { final items = json['items']; if (items is! List) { diff --git a/lib/src/ical/ical_event_projection.dart b/lib/src/ical/ical_event_projection.dart index 5fab3822..89fc1a80 100644 --- a/lib/src/ical/ical_event_projection.dart +++ b/lib/src/ical/ical_event_projection.dart @@ -211,6 +211,27 @@ ProjectedIcalEvent _projectOccurrence( ...master.extensionProperties, ...component.extensionProperties, }, + if (transport == 'caldav') + 'conferenceLinks': [ + for (final property in component.documentComponent.propertiesNamed( + 'CONFERENCE', + )) + { + 'url': property.rawValue, + 'features': property.parameterValue('FEATURE'), + }, + if (!component.documentComponent + .propertiesNamed('CONFERENCE') + .iterator + .moveNext()) + for (final property in master.documentComponent.propertiesNamed( + 'CONFERENCE', + )) + { + 'url': property.rawValue, + 'features': property.parameterValue('FEATURE'), + }, + ], }), ); } diff --git a/lib/src/ical/ical_import_service.dart b/lib/src/ical/ical_import_service.dart index 92a80864..e6fa306b 100644 --- a/lib/src/ical/ical_import_service.dart +++ b/lib/src/ical/ical_import_service.dart @@ -241,7 +241,14 @@ final class IcalImportService { _database.icalImportReceipts, )..where((row) => row.calendarSourceId.equals(destination.id))).get(); final importedUids = {for (final receipt in receipts) receipt.icalUid}; - final drafts = <({String icalUid, EventEditorDraft draft})>[]; + final drafts = + < + ({ + String icalUid, + EventEditorDraft draft, + List exceptions, + }) + >[]; final unsupported = []; var duplicates = 0; final omitted = {...preview.fieldsThatWillBeOmitted}; @@ -258,7 +265,11 @@ final class IcalImportService { continue; } omitted.addAll(prepared.omittedFields); - drafts.add((icalUid: set.uid, draft: prepared.draft!)); + drafts.add(( + icalUid: set.uid, + draft: prepared.draft!, + exceptions: prepared.exceptions, + )); } await _calendarRepository.createImportedEventsBatch( destination: destination, @@ -287,11 +298,6 @@ _PreparedImportDraft _prepareDraft( 'The recurrence set does not contain exactly one master event.', ); } - if (semantic.components.length != 1) { - return const _PreparedImportDraft.unsupported( - 'Detached recurrence exceptions are not supported by this destination import.', - ); - } final master = masters.single; final title = master.summary?.trim(); if (title == null || title.isEmpty) { @@ -444,6 +450,46 @@ _PreparedImportDraft _prepareDraft( final categories = destination.provider == BusyProvider.google ? const [] : master.categories; + if (!_supportedClassification(master.classification)) { + return _PreparedImportDraft.unsupported( + 'This iCalendar classification is not supported by ${destination.provider.displayName}.', + ); + } + final visibilityOrSensitivity = _classification( + master.classification, + destination.provider, + ); + if (destination.provider == BusyProvider.google) { + for (final component in set.semantic.components) { + if (component.recurrenceId == null || + component.status == 'CANCELLED' || + component.classification == null) { + continue; + } + if (_classification(component.classification, destination.provider) != + visibilityOrSensitivity) { + return const _PreparedImportDraft.unsupported( + 'Google cannot represent a detached occurrence with different visibility from its series.', + ); + } + } + } + final preparedExceptions = _prepareExceptions( + set, + master: master, + resolver: resolver, + destination: destination, + ); + if (preparedExceptions.reason case final reason?) { + return _PreparedImportDraft.unsupported(reason); + } + if (destination.provider == BusyProvider.google && + set.semantic.components.any( + (component) => + component.recurrenceId != null && component.categories.isNotEmpty, + )) { + omitted.add('categories'); + } return _PreparedImportDraft( draft: EventEditorDraft.newEvent( @@ -476,7 +522,7 @@ _PreparedImportDraft _prepareDraft( attendees: const [], attendeesChanged: false, showAs: _transparency(master.transparency, destination.provider), - visibilityOrSensitivity: master.classification?.toLowerCase(), + visibilityOrSensitivity: visibilityOrSensitivity, categories: categories, categoriesChanged: categories.isNotEmpty, createConference: false, @@ -485,9 +531,198 @@ _PreparedImportDraft _prepareDraft( ), reason: null, omittedFields: omitted, + exceptions: preparedExceptions.values, ); } +({List values, String? reason}) _prepareExceptions( + IcalRecurrenceSet set, { + required IcalSemanticComponent master, + required IcalTimeZoneResolver resolver, + required CalendarSourceEntity destination, +}) { + final exceptions = []; + if (set.semantic.components.any( + (component) => component.recurrenceId != null, + ) && + destination.provider != BusyProvider.google && + destination.provider != BusyProvider.microsoft) { + return ( + values: const [], + reason: 'This destination cannot safely import detached occurrences.', + ); + } + for (final component in set.semantic.components) { + final recurrenceId = component.recurrenceId; + if (recurrenceId == null) continue; + if (component.recurrenceRange != null || + component.recurrenceRules.isNotEmpty || + component.recurrenceDates.isNotEmpty) { + return ( + values: const [], + reason: 'This detached recurrence rule cannot be represented.', + ); + } + if (recurrenceId.kind == IcalTemporalKind.floatingDateTime || + (recurrenceId.kind == IcalTemporalKind.date) != + (master.start?.kind == IcalTemporalKind.date)) { + return ( + values: const [], + reason: 'The exception original start has an incompatible time type.', + ); + } + final String originalStart; + try { + if (recurrenceId.kind == IcalTemporalKind.date) { + originalStart = _dateOnly(_draftDateTime(recurrenceId)); + } else { + IcalTimeZoneResolver.system().toUtc(recurrenceId); + originalStart = resolver.toUtc(recurrenceId).toIso8601String(); + } + } on DavException { + return ( + values: const [], + reason: 'The exception timezone cannot be represented.', + ); + } + final cancelled = component.status == 'CANCELLED'; + final fields = {}; + if (!cancelled) { + if (!_supportedClassification(component.classification)) { + return ( + values: const [], + reason: + 'This iCalendar classification is not supported by ${destination.provider.displayName}.', + ); + } + if (component.classification != null) { + fields['sensitivity'] = _classification( + component.classification, + destination.provider, + ); + } + if (component.transparency != null) { + fields['transparencyOrShowAs'] = _transparency( + component.transparency, + destination.provider, + ); + } + if (destination.provider == BusyProvider.microsoft && + component.documentComponent.firstProperty('CATEGORIES') != null) { + fields['categoriesJson'] = component.categories; + } + if (component.summary case final title?) fields['title'] = title; + if (component.description case final description?) { + fields['description'] = description; + } + if (component.location case final location?) { + fields['location'] = location; + } + final start = component.start; + final end = component.end; + if ((start == null) != (end == null)) { + return ( + values: const [], + reason: 'A moved exception needs both start and end.', + ); + } + if (start != null && end != null) { + if (start.kind == IcalTemporalKind.floatingDateTime || + end.kind == IcalTemporalKind.floatingDateTime || + (start.kind == IcalTemporalKind.date) != + (end.kind == IcalTemporalKind.date)) { + return ( + values: const [], + reason: 'The moved exception uses incompatible time values.', + ); + } + try { + if (start.kind != IcalTemporalKind.date) { + IcalTimeZoneResolver.system().toUtc(start); + IcalTimeZoneResolver.system().toUtc(end); + if (!resolver.toUtc(end).isAfter(resolver.toUtc(start))) { + return ( + values: const [], + reason: 'The moved exception has a non-positive interval.', + ); + } + } else if (!_draftDateTime(end).isAfter(_draftDateTime(start))) { + return ( + values: const [], + reason: + 'The moved all-day exception has a non-positive interval.', + ); + } + } on DavException { + return ( + values: const [], + reason: 'The moved exception timezone cannot be represented.', + ); + } + fields['allDay'] = start.kind == IcalTemporalKind.date; + fields['start'] = _wireTemporal(start); + fields['end'] = _wireTemporal(end); + fields['startTimeZone'] = _timeZone(start); + fields['endTimeZone'] = _timeZone(end); + } + final minutes = _alarmMinutes(component.alarms); + if (minutes.isNotEmpty) { + fields['remindersJson'] = destination.provider == BusyProvider.microsoft + ? { + 'isReminderOn': true, + 'reminderMinutesBeforeStart': minutes.first, + } + : { + 'useDefault': false, + 'overrides': [ + for (final value in minutes) + {'method': 'popup', 'minutes': value}, + ], + }; + } + } + exceptions.add( + ImportedEventException( + originalStart: originalStart, + cancelled: cancelled, + fields: fields, + ), + ); + } + return (values: List.unmodifiable(exceptions), reason: null); +} + +bool _supportedClassification(String? value) => + value == null || + const { + 'PUBLIC', + 'PRIVATE', + 'CONFIDENTIAL', + }.contains(value.trim().toUpperCase()); + +String? _classification(String? value, BusyProvider provider) { + final normalized = value?.trim().toUpperCase(); + if (provider == BusyProvider.microsoft) { + return switch (normalized) { + 'PUBLIC' => 'normal', + 'PRIVATE' => 'private', + 'CONFIDENTIAL' => 'confidential', + _ => null, + }; + } + return normalized?.toLowerCase(); +} + +String _dateOnly(DateTime value) => + '${value.year.toString().padLeft(4, '0')}-${value.month.toString().padLeft(2, '0')}-${value.day.toString().padLeft(2, '0')}'; + +String _wireTemporal(IcalTemporalValue value) { + final instant = _draftDateTime(value); + return value.kind == IcalTemporalKind.date + ? _dateOnly(instant) + : instant.toIso8601String(); +} + DateTime _draftDateTime(IcalTemporalValue value) { final wall = value.localValue; if (value.kind == IcalTemporalKind.utcDateTime) return wall.toUtc(); @@ -541,9 +776,9 @@ List _alarmMinutes(List alarms) { if (trigger.parameterValue('RELATED')?.toUpperCase() == 'END') continue; try { final duration = parseIcalDuration(trigger.rawValue.toUpperCase()); - if (duration == null || !duration.negative) continue; + if (duration == null || duration.duration > Duration.zero) continue; final before = -duration.duration; - if (before.inSeconds <= 0 || before.inSeconds % 60 != 0) continue; + if (before.inSeconds % 60 != 0) continue; if (!values.contains(before.inMinutes)) values.add(before.inMinutes); } on DavException { // Unsupported alarms are intentionally omitted and reported by preview. @@ -557,14 +792,17 @@ final class _PreparedImportDraft { required this.draft, required this.reason, this.omittedFields = const {}, + this.exceptions = const [], }); const _PreparedImportDraft.unsupported(String reason) : draft = null, reason = reason, - omittedFields = const {}; + omittedFields = const {}, + exceptions = const []; final EventEditorDraft? draft; final String? reason; final Set omittedFields; + final List exceptions; } diff --git a/lib/src/microsoft_calendar/microsoft_calendar_api_client.dart b/lib/src/microsoft_calendar/microsoft_calendar_api_client.dart index 894dbfdf..7a3ccfec 100644 --- a/lib/src/microsoft_calendar/microsoft_calendar_api_client.dart +++ b/lib/src/microsoft_calendar/microsoft_calendar_api_client.dart @@ -1,33 +1,50 @@ import 'dart:convert'; +import '../calendar_providers/attachment_upload_session.dart'; + import 'package:http/http.dart' as http; import '../calendar_providers/calendar_mutation.dart'; import '../calendar_providers/calendar_provider_capabilities.dart'; import '../calendar_providers/calendar_sync_dto.dart'; import '../calendar_providers/cloud_calendar_client.dart'; +import '../core/http/request_dispatch_exception.dart'; +import '../core/time/provider_date_time.dart'; import 'package:busymax/src/providers/busy_provider.dart'; import 'microsoft_calendar_errors.dart'; import 'microsoft_calendar_mapper.dart'; import 'microsoft_calendar_models.dart'; +import 'microsoft_event_attachment.dart'; +import 'microsoft_shared_calendar_address.dart'; -class MicrosoftCalendarApiClient implements CloudCalendarClient { +class MicrosoftCalendarApiClient + implements CloudCalendarClient, DetailedFreeBusyClient { MicrosoftCalendarApiClient({ required http.Client httpClient, required Uri baseUri, required String responseTimeZone, + this.accountTenantId, Future Function()? authorizationHeaderProvider, + Future Function()? sharedCalendarAuthorizationHeaderProvider, + Future Function()? categoryAuthorizationHeaderProvider, Future Function()? unauthorizedRefreshProvider, }) : _httpClient = httpClient, _baseUri = baseUri, _responseTimeZone = responseTimeZone, _authorizationHeaderProvider = authorizationHeaderProvider, + _sharedCalendarAuthorizationHeaderProvider = + sharedCalendarAuthorizationHeaderProvider, + _categoryAuthorizationHeaderProvider = + categoryAuthorizationHeaderProvider, _unauthorizedRefreshProvider = unauthorizedRefreshProvider; final http.Client _httpClient; final Uri _baseUri; final String _responseTimeZone; + final String? accountTenantId; final Future Function()? _authorizationHeaderProvider; + final Future Function()? _sharedCalendarAuthorizationHeaderProvider; + final Future Function()? _categoryAuthorizationHeaderProvider; final Future Function()? _unauthorizedRefreshProvider; @override @@ -37,6 +54,91 @@ class MicrosoftCalendarApiClient implements CloudCalendarClient { CalendarProviderCapabilities get capabilities => microsoftCalendarProviderCapabilities; + /// Optional account-scoped metadata. A denied MailboxSettings.Read grant + /// must not prevent event or task mutations using existing category names. + Future> listMasterCategories() async { + final categories = []; + final seen = {}; + Uri? uri = _uri('/me/outlook/masterCategories'); + while (uri != null) { + if (!seen.add(uri)) { + throw const FormatException('Master category pagination loop.'); + } + final page = MicrosoftGraphCollectionPage.fromJson( + await _requestJson('GET', uri), + ); + categories.addAll(page.items.map(MicrosoftMasterCategory.fromJson)); + uri = page.nextLink == null ? null : _trustedNextLink(page.nextLink!); + } + return List.unmodifiable(categories); + } + + /// Graph's documented permission collection belongs to the signed-in + /// user's primary calendar. Other calendar IDs are not interchangeable. + Future> + listPrimaryCalendarPermissions() async { + final permissions = []; + final seen = {}; + Uri? uri = _uri('/me/calendar/calendarPermissions'); + while (uri != null) { + if (!seen.add(uri)) { + throw const FormatException('Calendar permission pagination loop.'); + } + final page = MicrosoftGraphCollectionPage.fromJson( + await _requestJson('GET', uri), + ); + permissions.addAll(page.items.map(MicrosoftCalendarPermission.fromJson)); + uri = page.nextLink == null ? null : _trustedNextLink(page.nextLink!); + } + return List.unmodifiable(permissions); + } + + Future addPrimaryCalendarPermission({ + required String email, + required String role, + }) async { + _validateNewShareRole(role); + if (!RegExp(r'^[^\s@/]+@[^\s@/]+\.[^\s@/]+$').hasMatch(email)) { + throw ArgumentError.value(email, 'email', 'Invalid recipient address.'); + } + final json = await _requestJson( + 'POST', + _uri('/me/calendar/calendarPermissions'), + body: { + 'emailAddress': {'address': email}, + 'role': role, + }, + ); + return MicrosoftCalendarPermission.fromJson(json); + } + + Future changePrimaryCalendarPermission( + MicrosoftCalendarPermission permission, + String role, + ) async { + if (!permission.allowedRoles.contains(role) || role == 'custom') { + throw ArgumentError.value(role, 'role', 'This role is not permitted.'); + } + final json = await _requestJson( + 'PATCH', + _uri('/me/calendar/calendarPermissions/${_enc(permission.id)}'), + body: {'role': role}, + ); + return MicrosoftCalendarPermission.fromJson(json); + } + + Future revokePrimaryCalendarPermission( + MicrosoftCalendarPermission permission, + ) { + if (!permission.isRemovable) { + throw ArgumentError('This calendar permission cannot be removed.'); + } + return _requestEmpty( + 'DELETE', + _uri('/me/calendar/calendarPermissions/${_enc(permission.id)}'), + ); + } + @override Future> listCalendars() async { final calendars = []; @@ -46,11 +148,68 @@ class MicrosoftCalendarApiClient implements CloudCalendarClient { await _requestJson('GET', uri), ); calendars.addAll(page.items.map(microsoftCalendarSourceFromJson)); - uri = _fullUriOrNull(page.nextLink); + uri = page.nextLink == null ? null : _trustedNextLink(page.nextLink!); } return calendars; } + /// Resolves an explicitly requested primary calendar in its owner's mailbox. + /// This is distinct from a recipient-local calendar returned by /me/calendars. + Future getSharedPrimaryCalendar(String owner) async { + final identity = owner.trim(); + if (!RegExp(r'^[^\s@/]+@[^\s@/]+\.[^\s@/]+$').hasMatch(identity)) { + throw const FormatException('A valid owner email address is required.'); + } + final json = await _requestJson( + 'GET', + _uri('/users/${_enc(identity)}/calendar'), + ); + final graphId = json['id']?.toString(); + if (graphId == null || graphId.isEmpty) { + throw const FormatException('Owner calendar has no Graph ID.'); + } + final key = MicrosoftSharedPrimaryCalendarAddress( + owner: identity, + graphCalendarId: graphId, + ).sourceCalendarId; + final mapped = microsoftCalendarSourceFromJson(json); + return CalendarSourceDto( + provider: mapped.provider, + providerCalendarId: key, + summary: mapped.summary, + description: mapped.description, + primaryCalendar: false, + selected: mapped.selected, + hidden: mapped.hidden, + // Missing canEdit is not evidence of delegated write permission. + readOnly: json['canEdit'] != true, + backgroundColor: mapped.backgroundColor, + foregroundColor: mapped.foregroundColor, + colorId: mapped.colorId, + timeZone: mapped.timeZone, + accessRole: json['canEdit'] == true ? 'writer' : 'reader', + dataOwner: identity, + isRemovable: true, + rawJson: { + ...json, + '_busymaxSharedPrimaryOwner': identity, + '_busymaxGraphCalendarId': graphId, + }, + ); + } + + String _calendarPath(String calendarId) { + final shared = MicrosoftSharedPrimaryCalendarAddress.parse(calendarId); + return shared == null + ? '/me/calendars/${_enc(calendarId)}' + : '/users/${_enc(shared.owner)}/calendars/${_enc(shared.graphCalendarId)}'; + } + + String _mailboxPath(String calendarId) { + final shared = MicrosoftSharedPrimaryCalendarAddress.parse(calendarId); + return shared == null ? '/me' : '/users/${_enc(shared.owner)}'; + } + @override Future createCalendar(CalendarMutation mutation) async { final json = await _requestJson( @@ -66,9 +225,12 @@ class MicrosoftCalendarApiClient implements CloudCalendarClient { String calendarId, CalendarMutation mutation, ) async { + if (MicrosoftSharedPrimaryCalendarAddress.parse(calendarId) != null) { + throw UnsupportedError('The owner calendar cannot be renamed here.'); + } final json = await _requestJson( 'PATCH', - _uri('/me/calendars/${_enc(calendarId)}'), + _uri(_calendarPath(calendarId)), body: microsoftCalendarMutationToJson(mutation), ); return microsoftCalendarSourceFromJson(json); @@ -76,7 +238,10 @@ class MicrosoftCalendarApiClient implements CloudCalendarClient { @override Future deleteCalendar(String calendarId) { - return _requestEmpty('DELETE', _uri('/me/calendars/${_enc(calendarId)}')); + if (MicrosoftSharedPrimaryCalendarAddress.parse(calendarId) != null) { + throw UnsupportedError('A delegated primary calendar cannot be deleted.'); + } + return _requestEmpty('DELETE', _uri(_calendarPath(calendarId))); } @override @@ -93,7 +258,7 @@ class MicrosoftCalendarApiClient implements CloudCalendarClient { rangeStart: rangeStart, rangeEnd: rangeEnd, ) - : _fullUriOrNull(pageTokenOrUrl); + : _trustedNextLink(pageTokenOrUrl); while (uri != null) { final page = MicrosoftGraphCollectionPage.fromJson( await _requestJson('GET', uri), @@ -103,7 +268,9 @@ class MicrosoftCalendarApiClient implements CloudCalendarClient { (item) => microsoftCalendarEventFromJson(calendarId, item), ), ); - uri = pageTokenOrUrl == null ? _fullUriOrNull(page.nextLink) : null; + uri = pageTokenOrUrl == null && page.nextLink != null + ? _trustedNextLink(page.nextLink!) + : null; } return events; } @@ -116,8 +283,8 @@ class MicrosoftCalendarApiClient implements CloudCalendarClient { await _requestJson( 'GET', nextLink == null - ? _uri('/me/calendars/${_enc(calendarId)}/events') - : Uri.parse(nextLink), + ? _uri('${_calendarPath(calendarId)}/events') + : _trustedNextLink(nextLink), ), ); return page.items @@ -125,6 +292,238 @@ class MicrosoftCalendarApiClient implements CloudCalendarClient { .toList(); } + /// Loads metadata only. Graph does not include attachments in the normal + /// event feed, and its hasAttachments flag does not imply an empty list. + Future> listEventAttachments({ + required String calendarId, + required String eventId, + }) async { + final result = []; + final seen = {}; + Uri? uri = _uri( + '${_calendarPath(calendarId)}/events/${_enc(eventId)}/attachments', + ); + while (uri != null) { + if (!seen.add(uri)) { + throw const FormatException('Attachment pagination loop.'); + } + final response = await _requestJson('GET', uri); + if (response['value'] is! List || + (response['value'] as List).any((item) => item is! Map)) { + throw const FormatException( + 'Malformed Microsoft event attachment list.', + ); + } + final page = MicrosoftGraphCollectionPage.fromJson(response); + result.addAll(page.items.map(MicrosoftEventAttachment.fromJson)); + uri = page.nextLink == null ? null : _trustedNextLink(page.nextLink!); + } + return result; + } + + Future> downloadEventAttachment({ + required String calendarId, + required String eventId, + required MicrosoftEventAttachment attachment, + }) async { + if (!attachment.canDownload) { + throw UnsupportedError( + 'This attachment has no downloadable file content.', + ); + } + final response = await _send( + 'GET', + _uri( + '${_calendarPath(calendarId)}/events/${_enc(eventId)}' + '/attachments/${_enc(attachment.id)}/\$value', + ), + ); + if (response.statusCode < 200 || response.statusCode >= 300) { + throw MicrosoftCalendarApiError.fromResponse(response); + } + return response.bodyBytes; + } + + Future createSmallEventAttachment({ + required String calendarId, + required String eventId, + required String name, + required String contentType, + required List bytes, + }) async { + _checkAttachmentName(name); + if (bytes.length >= 3 * 1024 * 1024) { + throw ArgumentError.value( + bytes.length, + 'bytes', + 'Use an upload session.', + ); + } + final response = await _requestJson( + 'POST', + _uri('${_calendarPath(calendarId)}/events/${_enc(eventId)}/attachments'), + body: { + '@odata.type': '#microsoft.graph.fileAttachment', + 'name': name, + 'contentType': contentType, + 'contentBytes': base64Encode(bytes), + }, + ); + return MicrosoftEventAttachment.fromJson(response); + } + + Future uploadEventFileAttachment({ + required String calendarId, + required String eventId, + required String name, + required String contentType, + required List bytes, + MicrosoftAttachmentUploadSession? resumeSession, + void Function(MicrosoftAttachmentUploadSession)? onSession, + }) async { + _checkAttachmentName(name); + if (bytes.length > 150 * 1024 * 1024) { + throw ArgumentError.value( + bytes.length, + 'bytes', + 'Event file exceeds 150 MB.', + ); + } + if (bytes.length < 3 * 1024 * 1024 && resumeSession == null) { + final created = await createSmallEventAttachment( + calendarId: calendarId, + eventId: eventId, + name: name, + contentType: contentType, + bytes: bytes, + ); + if (created.id.isEmpty) { + throw const MicrosoftAttachmentUploadUncertain(); + } + return created.id; + } + MicrosoftAttachmentUploadSession session; + if (resumeSession == null) { + // Outlook's session action is event-scoped, not calendar-scoped. + final created = await _requestJson( + 'POST', + _uri( + '${_mailboxPath(calendarId)}/events/${_enc(eventId)}/attachments/createUploadSession', + ), + body: { + 'AttachmentItem': { + 'attachmentType': 'file', + 'name': name, + 'size': bytes.length, + }, + }, + ); + final uploadUri = Uri.tryParse(created['uploadUrl']?.toString() ?? ''); + if (uploadUri == null || + uploadUri.scheme != 'https' || + uploadUri.host.isEmpty || + uploadUri.userInfo.isNotEmpty) { + throw const FormatException('Invalid Outlook attachment upload URL.'); + } + session = MicrosoftAttachmentUploadSession( + url: uploadUri, + expiresAt: null, + nextOffset: 0, + ); + session.update(created, bytes.length); + onSession?.call(session); // Retain the opaque URL before the first PUT. + } else { + session = resumeSession; + try { + final response = await _httpClient.get(session.url); + if (response.statusCode != 200) { + throw const MicrosoftAttachmentUploadUncertain(); + } + session.update( + (jsonDecode(response.body) as Map).cast(), + bytes.length, + ); + } on Object { + throw const MicrosoftAttachmentUploadUncertain(); + } + } + if (session.expiresAt case final expiry?) { + if (!expiry.isAfter(DateTime.now().toUtc())) { + throw const MicrosoftAttachmentUploadUncertain(); + } + } + var offset = session.nextOffset; + if (offset == bytes.length) { + throw const MicrosoftAttachmentUploadUncertain(); + } + const chunkSize = 2 * 1024 * 1024; + while (offset < bytes.length) { + final end = offset + chunkSize < bytes.length + ? offset + chunkSize + : bytes.length; + try { + if (end == bytes.length) { + session.finalRangeMayHaveBeenSubmitted = true; + } + final response = await _httpClient.put( + session.url, + headers: { + // This opaque Outlook URL is pre-authenticated. Never include the + // Graph bearer token, including when it has a different host. + 'Content-Type': 'application/octet-stream', + 'Content-Length': '${end - offset}', + 'Content-Range': 'bytes $offset-${end - 1}/${bytes.length}', + }, + body: bytes.sublist(offset, end), + ); + if (end == bytes.length) { + if (response.statusCode != 201) { + throw const MicrosoftAttachmentUploadUncertain(); + } + return attachmentIdFromUploadHeaders(response.headers); + } + if (response.statusCode != 200) { + throw const MicrosoftAttachmentUploadUncertain(); + } + session.update( + (jsonDecode(response.body) as Map).cast(), + bytes.length, + ); + if (session.nextOffset <= offset) { + throw const MicrosoftAttachmentUploadUncertain(); + } + } on Object { + // This includes a lost response after the service accepted the chunk. + // The coordinator retains the session and queries its acknowledged + // range before sending another byte. + throw const MicrosoftAttachmentUploadUncertain(); + } + offset = session.nextOffset; + } + throw const MicrosoftAttachmentUploadUncertain(); + } + + Future cancelEventAttachmentUpload( + MicrosoftAttachmentUploadSession session, + ) async { + final response = await _httpClient.delete(session.url); + if (response.statusCode != 204) { + throw const MicrosoftAttachmentUploadUncertain(); + } + } + + Future deleteEventAttachment({ + required String calendarId, + required String eventId, + required String attachmentId, + }) => _requestEmpty( + 'DELETE', + _uri( + '${_calendarPath(calendarId)}/events/${_enc(eventId)}' + '/attachments/${_enc(attachmentId)}', + ), + ); + @override Future createEvent({ required String calendarId, @@ -134,7 +533,7 @@ class MicrosoftCalendarApiClient implements CloudCalendarClient { }) async { final json = await _requestJson( 'POST', - _uri('/me/calendars/${_enc(calendarId)}/events'), + _uri('${_calendarPath(calendarId)}/events'), body: microsoftEventMutationToJson(mutation), ); return microsoftCalendarEventFromJson(calendarId, json); @@ -147,7 +546,7 @@ class MicrosoftCalendarApiClient implements CloudCalendarClient { }) async { final json = await _requestJson( 'GET', - _uri('/me/calendars/${_enc(calendarId)}/events/${_enc(eventId)}'), + _uri('${_calendarPath(calendarId)}/events/${_enc(eventId)}'), ); return microsoftCalendarEventFromJson(calendarId, json); } @@ -163,7 +562,7 @@ class MicrosoftCalendarApiClient implements CloudCalendarClient { }) async { final json = await _requestJson( 'PATCH', - _uri('/me/calendars/${_enc(calendarId)}/events/${_enc(eventId)}'), + _uri('${_calendarPath(calendarId)}/events/${_enc(eventId)}'), body: microsoftEventMutationToJson(mutation), ); return microsoftCalendarEventFromJson(calendarId, json); @@ -179,7 +578,7 @@ class MicrosoftCalendarApiClient implements CloudCalendarClient { }) { return _requestEmpty( 'DELETE', - _uri('/me/calendars/${_enc(calendarId)}/events/${_enc(eventId)}'), + _uri('${_calendarPath(calendarId)}/events/${_enc(eventId)}'), ); } @@ -207,7 +606,7 @@ class MicrosoftCalendarApiClient implements CloudCalendarClient { await _requestEmpty( 'POST', _uri( - '/me/calendars/${_enc(calendarId)}/events/${_enc(eventId)}/' + '${_calendarPath(calendarId)}/events/${_enc(eventId)}/' '${_microsoftInvitationAction(response)}', ), body: {'sendResponse': sendResponse}, @@ -224,7 +623,7 @@ class MicrosoftCalendarApiClient implements CloudCalendarClient { }) async { final events = []; Uri? uri = _uri( - '/me/calendars/${_enc(calendarId)}/events/${_enc(recurringEventId)}' + '${_calendarPath(calendarId)}/events/${_enc(recurringEventId)}' '/instances', query: { 'startDateTime': _graphDateTime(rangeStart), @@ -240,11 +639,60 @@ class MicrosoftCalendarApiClient implements CloudCalendarClient { (item) => microsoftCalendarEventFromJson(calendarId, item), ), ); - uri = _fullUriOrNull(page.nextLink); + uri = page.nextLink == null ? null : _trustedNextLink(page.nextLink!); } return events; } + /// Reads the master's exception identities independently of an instance + /// time window. A moved exception may no longer appear in the window of its + /// original occurrence, so import recovery cannot rely on /instances alone. + Future<({List exceptions, Set cancelledIds})> + getSeriesExceptionSnapshot({ + required String calendarId, + required String recurringEventId, + }) async { + final json = await _requestJson( + 'GET', + _uri( + '${_calendarPath(calendarId)}/events/${_enc(recurringEventId)}', + query: { + r'$select': + 'id,subject,start,end,originalStart,occurrenceId,' + 'exceptionOccurrences,cancelledOccurrences', + r'$expand': 'exceptionOccurrences', + }, + ), + ); + final rawExceptions = json['exceptionOccurrences']; + final rawCancelled = json['cancelledOccurrences']; + if (rawExceptions is! List || + rawCancelled is! List || + json.containsKey('exceptionOccurrences@odata.nextLink') || + json.containsKey('cancelledOccurrences@odata.nextLink')) { + throw const FormatException('Malformed series exception collection.'); + } + return ( + exceptions: [ + for (final value in rawExceptions) + if (value is Map) + microsoftCalendarEventFromJson( + calendarId, + Map.from(value), + ) + else + throw const FormatException('Malformed series exception event.'), + ], + cancelledIds: { + for (final value in rawCancelled) + if (value is String) + value + else + throw const FormatException('Malformed cancelled occurrence ID.'), + }, + ); + } + @override Future syncEvents({ required String calendarId, @@ -257,7 +705,7 @@ class MicrosoftCalendarApiClient implements CloudCalendarClient { final hasContinuation = syncTokenOrDeltaLink != null && syncTokenOrDeltaLink.isNotEmpty; final uri = hasContinuation - ? Uri.parse(syncTokenOrDeltaLink) + ? _trustedNextLink(syncTokenOrDeltaLink) : primaryCalendar ? _primaryCalendarViewDeltaUri( rangeStart: rangeStart, @@ -292,7 +740,117 @@ class MicrosoftCalendarApiClient implements CloudCalendarClient { required DateTime rangeStart, required DateTime rangeEnd, }) async { - return const []; + final results = await freeBusyDetails( + calendarIds: calendarIds, + rangeStart: rangeStart, + rangeEnd: rangeEnd, + ); + if (results.any((result) => !result.succeeded)) { + throw StateError('Microsoft availability is incomplete.'); + } + return [for (final result in results) ...result.busySlots]; + } + + @override + Future> freeBusyDetails({ + required List calendarIds, + required DateTime rangeStart, + required DateTime rangeEnd, + }) async { + if (calendarIds.isEmpty) return const []; + List failed(String reason) => [ + for (final id in calendarIds) + FreeBusyCalendarResultDto( + calendarId: id, + status: FreeBusyEvaluationStatus.failed, + errors: [reason], + ), + ]; + final start = rangeStart.toUtc(); + final end = rangeEnd.toUtc(); + if (!end.isAfter(start) || + end.difference(start) >= const Duration(days: 62)) { + return failed( + 'Microsoft availability requires a positive range shorter than 62 days.', + ); + } + switch (microsoftAvailabilityAccountType(accountTenantId)) { + case MicrosoftAvailabilityAccountType.personal: + return failed( + 'Microsoft personal accounts do not support availability lookup.', + ); + case MicrosoftAvailabilityAccountType.unknown: + return failed( + 'Microsoft account type is unknown; availability is unavailable.', + ); + case MicrosoftAvailabilityAccountType.workSchool: + break; + } + final results = []; + for (var offset = 0; offset < calendarIds.length; offset += 20) { + final batch = calendarIds.skip(offset).take(20).toList(); + final requested = batch.where((id) => id.trim().isNotEmpty).toList(); + final mapped = {}; + for (final id in batch.where((id) => id.trim().isEmpty)) { + mapped[id.toLowerCase()] = FreeBusyCalendarResultDto( + calendarId: id, + status: FreeBusyEvaluationStatus.failed, + errors: const ['A recipient address is required.'], + ); + } + if (requested.isNotEmpty) { + try { + final response = await _requestJson( + 'POST', + _uri('/me/calendar/getSchedule'), + body: { + 'schedules': requested, + 'startTime': _utcScheduleTime(start), + 'endTime': _utcScheduleTime(end), + 'availabilityViewInterval': 30, + }, + ); + final value = response['value']; + if (value is! List) { + throw const FormatException( + 'Microsoft availability response has no recipient results.', + ); + } + for (final raw in value) { + if (raw is! Map) continue; + final item = raw.cast(); + final id = item['scheduleId']?.toString(); + if (id == null || + !requested.any( + (requestedId) => + requestedId.toLowerCase() == id.toLowerCase(), + )) { + continue; + } + mapped[id.toLowerCase()] = _scheduleResult(id, item); + } + } on Object catch (error) { + for (final id in requested) { + mapped[id.toLowerCase()] = FreeBusyCalendarResultDto( + calendarId: id, + status: FreeBusyEvaluationStatus.failed, + errors: ['$error'], + ); + } + } + } + for (final id in batch) { + results.add( + mapped[id.toLowerCase()] ?? + FreeBusyCalendarResultDto( + calendarId: id, + status: FreeBusyEvaluationStatus.missing, + errors: const ['The provider omitted this recipient.'], + ), + ); + } + } + return results; } Future _collectionPage(Uri uri) async { @@ -307,7 +865,7 @@ class MicrosoftCalendarApiClient implements CloudCalendarClient { required DateTime rangeEnd, }) { return _uri( - '/me/calendars/${_enc(calendarId)}/calendarView', + '${_calendarPath(calendarId)}/calendarView', query: { 'startDateTime': _graphDateTime(rangeStart), 'endDateTime': _graphDateTime(rangeEnd), @@ -360,13 +918,31 @@ class MicrosoftCalendarApiClient implements CloudCalendarClient { Map? body, bool retried = false, }) async { - final authorizationHeaderProvider = _authorizationHeaderProvider; + _trustedNextLink(uri.toString()); + final authorizationHeaderProvider = + uri.pathSegments.contains('masterCategories') + ? _categoryAuthorizationHeaderProvider ?? _authorizationHeaderProvider + : uri.pathSegments.contains('users') + ? _sharedCalendarAuthorizationHeaderProvider ?? + _authorizationHeaderProvider + : _authorizationHeaderProvider; + String? authorizationHeader; + try { + authorizationHeader = await authorizationHeaderProvider?.call(); + } on Object catch (error, stackTrace) { + Error.throwWithStackTrace( + KnownUnsentRequestException( + kind: RequestPreDispatchFailureKind.authentication, + cause: error, + ), + stackTrace, + ); + } final headers = { 'Accept': 'application/json', 'Prefer': 'outlook.timezone="$_responseTimeZone"', if (body != null) 'Content-Type': 'application/json', - if (authorizationHeaderProvider != null) - 'Authorization': await authorizationHeaderProvider(), + if (authorizationHeader != null) 'Authorization': authorizationHeader, }; final encodedBody = body == null ? null : jsonEncode(body); final response = switch (method) { @@ -388,7 +964,17 @@ class MicrosoftCalendarApiClient implements CloudCalendarClient { if (response.statusCode == 401 && !retried && unauthorizedRefreshProvider != null) { - await unauthorizedRefreshProvider(); + try { + await unauthorizedRefreshProvider(); + } on Object catch (error, stackTrace) { + Error.throwWithStackTrace( + KnownUnsentRequestException( + kind: RequestPreDispatchFailureKind.authentication, + cause: error, + ), + stackTrace, + ); + } return _send(method, uri, body: body, retried: true); } return response; @@ -403,22 +989,134 @@ class MicrosoftCalendarApiClient implements CloudCalendarClient { queryParameters: query == null || query.isEmpty ? null : query, ); } -} -Uri? _fullUriOrNull(String? value) { - if (value == null || value.isEmpty) { - return null; + Uri _trustedNextLink(String value) { + final uri = Uri.tryParse(value); + final pathPrefix = _baseUri.path.endsWith('/') + ? _baseUri.path + : '${_baseUri.path}/'; + if (uri == null || + uri.scheme != _baseUri.scheme || + uri.host != _baseUri.host || + uri.port != _baseUri.port || + uri.userInfo.isNotEmpty || + !uri.path.startsWith(pathPrefix)) { + throw const FormatException( + 'Untrusted Microsoft Graph continuation URL.', + ); + } + return uri; } - return Uri.parse(value); } String _enc(String value) => Uri.encodeComponent(value); +void _checkAttachmentName(String name) { + final value = name.trim(); + if (value.isEmpty || + value == '.' || + value == '..' || + value.length > 255 || + value.contains('/') || + value.contains('\\') || + value.codeUnits.any((unit) => unit < 32 || unit == 127)) { + throw ArgumentError.value(name, 'name', 'Invalid attachment name.'); + } +} + String _graphDateTime(DateTime value) => value.toUtc().toIso8601String(); +Map _utcScheduleTime(DateTime value) => { + 'dateTime': value.toUtc().toIso8601String().replaceFirst(RegExp(r'Z$'), ''), + 'timeZone': 'UTC', +}; + +FreeBusyCalendarResultDto _scheduleResult( + String id, + Map response, +) { + final error = response['error']; + if (error is Map) { + final code = error['responseCode']?.toString(); + final message = error['message']?.toString(); + return FreeBusyCalendarResultDto( + calendarId: id, + status: FreeBusyEvaluationStatus.failed, + errors: [ + if (code != null && code.isNotEmpty) code, + if (message != null && message.isNotEmpty) message, + ], + ); + } + final items = response['scheduleItems']; + if (items is! List) { + return FreeBusyCalendarResultDto( + calendarId: id, + status: FreeBusyEvaluationStatus.missing, + errors: const ['The provider omitted schedule information.'], + ); + } + final slots = []; + for (final raw in items) { + if (raw is! Map) { + return FreeBusyCalendarResultDto( + calendarId: id, + status: FreeBusyEvaluationStatus.failed, + errors: const ['Invalid schedule item.'], + ); + } + final item = raw.cast(); + final status = item['status']?.toString().toLowerCase(); + if (status == 'free' || status == 'workingelsewhere') continue; + if (status != 'busy' && status != 'tentative' && status != 'oof') { + return FreeBusyCalendarResultDto( + calendarId: id, + status: FreeBusyEvaluationStatus.failed, + errors: const ['Unknown schedule item status.'], + ); + } + final startValue = item['start']; + final endValue = item['end']; + if (startValue is! Map || endValue is! Map) { + return FreeBusyCalendarResultDto( + calendarId: id, + status: FreeBusyEvaluationStatus.failed, + errors: const ['Schedule item is missing its interval.'], + ); + } + final start = providerDateTimeAsUtcInstant( + startValue['dateTime']?.toString(), + startValue['timeZone']?.toString(), + ); + final end = providerDateTimeAsUtcInstant( + endValue['dateTime']?.toString(), + endValue['timeZone']?.toString(), + ); + if (start == null || end == null || !end.isAfter(start)) { + return FreeBusyCalendarResultDto( + calendarId: id, + status: FreeBusyEvaluationStatus.failed, + errors: const ['Schedule item has an invalid interval.'], + ); + } + slots.add(BusySlotDto(calendarId: id, start: start, end: end)); + } + return FreeBusyCalendarResultDto( + calendarId: id, + status: FreeBusyEvaluationStatus.success, + busySlots: slots, + ); +} + String _microsoftInvitationAction(CalendarInvitationResponse response) => switch (response) { CalendarInvitationResponse.accept => 'accept', CalendarInvitationResponse.tentative => 'tentativelyAccept', CalendarInvitationResponse.decline => 'decline', }; + +void _validateNewShareRole(String role) { + if (!const {'freeBusyRead', 'limitedRead', 'read', 'write'}.contains(role)) { + throw ArgumentError.value(role, 'role', 'Unsupported sharing role.'); + } +} diff --git a/lib/src/microsoft_calendar/microsoft_calendar_errors.dart b/lib/src/microsoft_calendar/microsoft_calendar_errors.dart index 3d607fc0..2f81ec0c 100644 --- a/lib/src/microsoft_calendar/microsoft_calendar_errors.dart +++ b/lib/src/microsoft_calendar/microsoft_calendar_errors.dart @@ -2,11 +2,14 @@ import 'dart:convert'; import 'package:http/http.dart' as http; +import '../core/http/retry_after.dart'; + class MicrosoftCalendarApiError implements Exception { const MicrosoftCalendarApiError({ required this.statusCode, required this.code, required this.message, + this.retryAfter, }); factory MicrosoftCalendarApiError.fromResponse(http.Response response) { @@ -30,12 +33,16 @@ class MicrosoftCalendarApiError implements Exception { statusCode: response.statusCode, code: code, message: message, + retryAfter: parseHttpRetryAfter(response.headers['retry-after']), ); } final int statusCode; final String code; final String message; + final Duration? retryAfter; + + bool get isRateLimited => statusCode == 429; bool get isInvalidSyncState { final normalizedCode = code.toLowerCase(); diff --git a/lib/src/microsoft_calendar/microsoft_calendar_mapper.dart b/lib/src/microsoft_calendar/microsoft_calendar_mapper.dart index 78290111..90a7f9f7 100644 --- a/lib/src/microsoft_calendar/microsoft_calendar_mapper.dart +++ b/lib/src/microsoft_calendar/microsoft_calendar_mapper.dart @@ -100,14 +100,22 @@ CalendarEventDto microsoftCalendarEventFromJson( attendeesJson: json['attendees'], categoriesJson: json['categories'], organizerJson: json['organizer'], - colorId: _firstCategory(json['categories']), + // Outlook category names are not color IDs. Their color is resolved from + // the account's master-category collection, never from array position. + colorId: null, colorHex: null, visibility: json['sensitivity']?.toString(), transparencyOrShowAs: json['showAs']?.toString(), eventType: json['type']?.toString(), webLink: json['webLink']?.toString(), conferenceJson: json['onlineMeeting'], - attachmentsJson: json['hasAttachments'] == true ? const [] : null, + // Graph's hasAttachments flag is not a listing. An empty collection is + // authoritative only when Graph actually supplied one (or said false). + attachmentsJson: json['attachments'] is List + ? json['attachments'] + : json['hasAttachments'] == false + ? const [] + : null, isCancelled: json['isCancelled'] == true, isDeleted: json['@removed'] != null, createdAtServer: json['createdDateTime']?.toString(), @@ -233,13 +241,6 @@ Map _mapValue(Object? value) { return const {}; } -String? _firstCategory(Object? value) { - if (value is List && value.isNotEmpty) { - return value.first?.toString(); - } - return null; -} - String? _dateOnly(Object? value) { final text = value?.toString(); if (text == null || text.length < 10) { diff --git a/lib/src/microsoft_calendar/microsoft_calendar_models.dart b/lib/src/microsoft_calendar/microsoft_calendar_models.dart index b68184c2..428ef63a 100644 --- a/lib/src/microsoft_calendar/microsoft_calendar_models.dart +++ b/lib/src/microsoft_calendar/microsoft_calendar_models.dart @@ -7,13 +7,14 @@ class MicrosoftGraphCollectionPage { factory MicrosoftGraphCollectionPage.fromJson(Map json) { final value = json['value']; + if (value is! List || value.any((item) => item is! Map)) { + throw const FormatException('Malformed Microsoft Graph collection.'); + } return MicrosoftGraphCollectionPage( - items: value is List - ? value - .whereType() - .map((item) => item.cast()) - .toList() - : const [], + items: value + .cast() + .map((item) => Map.from(item)) + .toList(), nextLink: json['@odata.nextLink']?.toString(), deltaLink: json['@odata.deltaLink']?.toString(), ); @@ -23,3 +24,116 @@ class MicrosoftGraphCollectionPage { final String? nextLink; final String? deltaLink; } + +/// Outlook's account-owned category catalog. Category names on events and +/// tasks remain authoritative even when this optional lookup is unavailable. +final class MicrosoftMasterCategory { + const MicrosoftMasterCategory({ + required this.id, + required this.displayName, + required this.color, + }); + + factory MicrosoftMasterCategory.fromJson(Map json) { + final id = json['id']; + final name = json['displayName']; + final color = json['color']; + if (id is! String || + id.isEmpty || + name is! String || + name.isEmpty || + color is! String || + color.isEmpty) { + throw const FormatException('Malformed Outlook master category.'); + } + return MicrosoftMasterCategory(id: id, displayName: name, color: color); + } + + final String id; + final String displayName; + final String color; +} + +/// Representative swatches for Graph's Outlook category-color constants. +/// Outlook clients may render a different shade of the same named color. +int? microsoftCategorySwatchArgb(String preset) { + const swatches = [ + 0xffd13438, // Red + 0xffef6950, // Orange + 0xff8e562e, // Brown + 0xffe9b437, // Yellow + 0xff498205, // Green + 0xff00a2ae, // Teal + 0xff8c8e25, // Olive + 0xff0078d4, // Blue + 0xff8764b8, // Purple + 0xffc239b3, // Cranberry + 0xff69797e, // Steel + 0xff4f5d62, // Dark steel + 0xff8a8886, // Gray + 0xff605e5c, // Dark gray + 0xff323130, // Black + 0xffa4262c, // Dark red + 0xffca5010, // Dark orange + 0xff6b3b16, // Dark brown + 0xffa79a26, // Dark yellow + 0xff0b6a0b, // Dark green + 0xff038387, // Dark teal + 0xff6a6b18, // Dark olive + 0xff004578, // Dark blue + 0xff5c2e91, // Dark purple + 0xff931d7d, // Dark cranberry + ]; + final match = RegExp( + r'^preset(\d+)$', + caseSensitive: false, + ).firstMatch(preset); + if (match == null) return null; + final index = int.tryParse(match.group(1)!); + return index == null || index >= swatches.length ? null : swatches[index]; +} + +final class MicrosoftCalendarPermission { + const MicrosoftCalendarPermission({ + required this.id, + required this.role, + required this.allowedRoles, + required this.isRemovable, + this.name, + this.address, + }); + + factory MicrosoftCalendarPermission.fromJson(Map json) { + final id = json['id']; + final role = json['role']; + final allowed = json['allowedRoles']; + if (id is! String || + id.isEmpty || + role is! String || + role.isEmpty || + allowed is! List || + allowed.any((value) => value is! String) || + json['isRemovable'] is! bool) { + throw const FormatException('Malformed Microsoft calendar permission.'); + } + final email = json['emailAddress']; + if (email != null && email is! Map) { + throw const FormatException('Malformed permission recipient.'); + } + return MicrosoftCalendarPermission( + id: id, + role: role, + allowedRoles: List.unmodifiable(allowed.cast()), + isRemovable: json['isRemovable'] as bool, + name: email is Map ? email['name']?.toString() : null, + address: email is Map ? email['address']?.toString() : null, + ); + } + + final String id; + final String role; + final List allowedRoles; + final bool isRemovable; + final String? name; + final String? address; +} diff --git a/lib/src/microsoft_calendar/microsoft_event_attachment.dart b/lib/src/microsoft_calendar/microsoft_event_attachment.dart new file mode 100644 index 00000000..a6e515a9 --- /dev/null +++ b/lib/src/microsoft_calendar/microsoft_event_attachment.dart @@ -0,0 +1,43 @@ +enum MicrosoftEventAttachmentKind { file, item, reference, unknown } + +/// Metadata returned by the event-scoped Graph attachment collection. +/// File contents are requested separately only after an explicit action. +final class MicrosoftEventAttachment { + const MicrosoftEventAttachment({ + required this.id, + required this.name, + required this.kind, + this.size, + this.contentType, + this.sourceUrl, + }); + + factory MicrosoftEventAttachment.fromJson(Map json) { + return MicrosoftEventAttachment( + id: json['id']?.toString() ?? '', + name: json['name']?.toString() ?? '', + kind: switch (json['@odata.type']?.toString().toLowerCase()) { + '#microsoft.graph.fileattachment' => MicrosoftEventAttachmentKind.file, + '#microsoft.graph.itemattachment' => MicrosoftEventAttachmentKind.item, + '#microsoft.graph.referenceattachment' => + MicrosoftEventAttachmentKind.reference, + _ => MicrosoftEventAttachmentKind.unknown, + }, + size: json['size'] is int ? json['size'] as int : null, + contentType: json['contentType']?.toString(), + sourceUrl: json['sourceUrl']?.toString(), + ); + } + + final String id; + final String name; + final MicrosoftEventAttachmentKind kind; + final int? size; + final String? contentType; + final String? sourceUrl; + + bool get canDownload => + id.isNotEmpty && + (kind == MicrosoftEventAttachmentKind.file || + kind == MicrosoftEventAttachmentKind.item); +} diff --git a/lib/src/microsoft_calendar/microsoft_shared_calendar_address.dart b/lib/src/microsoft_calendar/microsoft_shared_calendar_address.dart new file mode 100644 index 00000000..e1364704 --- /dev/null +++ b/lib/src/microsoft_calendar/microsoft_shared_calendar_address.dart @@ -0,0 +1,41 @@ +import 'dart:convert'; + +/// A local source key that keeps an owner-mailbox calendar distinct from a +/// recipient-local share with the same Graph calendar ID. +final class MicrosoftSharedPrimaryCalendarAddress { + const MicrosoftSharedPrimaryCalendarAddress({ + required this.owner, + required this.graphCalendarId, + }); + + final String owner; + final String graphCalendarId; + + static const _prefix = 'busymax-shared-primary:'; + + String get sourceCalendarId => + '$_prefix${base64Url.encode(utf8.encode(owner))}:${base64Url.encode(utf8.encode(graphCalendarId))}'; + + static MicrosoftSharedPrimaryCalendarAddress? parse(String value) { + if (!value.startsWith(_prefix)) return null; + final parts = value.substring(_prefix.length).split(':'); + if (parts.length != 2) { + throw const FormatException('Invalid owner calendar key.'); + } + try { + final owner = utf8.decode( + base64Url.decode(base64Url.normalize(parts[0])), + ); + final id = utf8.decode(base64Url.decode(base64Url.normalize(parts[1]))); + if (owner.isEmpty || id.isEmpty) { + throw const FormatException('Invalid owner calendar key.'); + } + return MicrosoftSharedPrimaryCalendarAddress( + owner: owner, + graphCalendarId: id, + ); + } on FormatException { + throw const FormatException('Invalid owner calendar key.'); + } + } +} 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 e7d637c5..84bc3013 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 '../../calendar_providers/attachment_upload_session.dart'; import '../../core/http/request_dispatch_exception.dart'; import 'microsoft_todo_api_error.dart'; import 'microsoft_todo_api_models.dart'; @@ -72,8 +73,57 @@ abstract interface class MicrosoftTodoChecklistApiClient { }); } +abstract interface class MicrosoftTodoLinkedResourcesApiClient { + Future listLinkedResourcesPage({ + required String taskListId, + required String taskId, + String? nextLink, + }); +} + +abstract interface class MicrosoftTodoAttachmentsApiClient { + Future listTaskAttachmentsPage({ + required String taskListId, + required String taskId, + String? nextLink, + }); + Future> downloadTaskAttachment({ + required String taskListId, + required String taskId, + required String attachmentId, + }); + Future createSmallTaskAttachment({ + required String taskListId, + required String taskId, + required String name, + required String contentType, + required List bytes, + }); + Future deleteTaskAttachment({ + required String taskListId, + required String taskId, + required String attachmentId, + }); + Future uploadTaskFileAttachment({ + required String taskListId, + required String taskId, + required String name, + required String contentType, + required List bytes, + MicrosoftAttachmentUploadSession? resumeSession, + void Function(MicrosoftAttachmentUploadSession)? onSession, + }); + Future cancelTaskAttachmentUpload( + MicrosoftAttachmentUploadSession session, + ); +} + class MicrosoftTodoRestApiClient - implements MicrosoftTodoApiClient, MicrosoftTodoChecklistApiClient { + implements + MicrosoftTodoApiClient, + MicrosoftTodoChecklistApiClient, + MicrosoftTodoLinkedResourcesApiClient, + MicrosoftTodoAttachmentsApiClient { MicrosoftTodoRestApiClient({ required http.Client httpClient, required Uri baseUri, @@ -202,6 +252,233 @@ class MicrosoftTodoRestApiClient return MicrosoftTodoTaskDto.fromJson(json); } + @override + Future listLinkedResourcesPage({ + required String taskListId, + required String taskId, + String? nextLink, + }) async { + final uri = nextLink == null + ? _uri(microsoftLinkedResourcesPath(taskListId, taskId)) + : _trustedNextLink(nextLink); + final json = await _requestJson('GET', uri); + return MicrosoftTodoLinkedResourcesPageDto.fromJson(json); + } + + @override + Future listTaskAttachmentsPage({ + required String taskListId, + required String taskId, + String? nextLink, + }) async { + final uri = nextLink == null + ? _uri(microsoftTaskAttachmentsPath(taskListId, taskId)) + : _trustedNextLink(nextLink); + return MicrosoftTodoAttachmentsPageDto.fromJson( + await _requestJson('GET', uri), + ); + } + + @override + Future> downloadTaskAttachment({ + required String taskListId, + required String taskId, + required String attachmentId, + }) async { + final response = await _send( + 'GET', + _uri( + '${microsoftTaskAttachmentPath(taskListId, taskId, attachmentId)}/\$value', + ), + ); + if (response.statusCode < 200 || response.statusCode >= 300) { + throw MicrosoftTodoApiError.fromResponse( + statusCode: response.statusCode, + body: response.body, + headers: response.headers, + ); + } + return response.bodyBytes; + } + + @override + Future createSmallTaskAttachment({ + required String taskListId, + required String taskId, + required String name, + required String contentType, + required List bytes, + }) async { + if (name.trim().isEmpty || + name.contains('/') || + name.contains('\\') || + bytes.length >= 3 * 1024 * 1024) { + throw ArgumentError( + 'Invalid name or file exceeds the direct-upload limit.', + ); + } + return MicrosoftTodoAttachmentDto.fromJson( + await _requestJson( + 'POST', + _uri(microsoftTaskAttachmentsPath(taskListId, taskId)), + body: { + '@odata.type': '#microsoft.graph.taskFileAttachment', + 'name': name, + 'contentType': contentType, + 'size': bytes.length, + 'contentBytes': base64Encode(bytes), + }, + ), + ); + } + + @override + Future deleteTaskAttachment({ + required String taskListId, + required String taskId, + required String attachmentId, + }) => _requestEmpty( + 'DELETE', + _uri(microsoftTaskAttachmentPath(taskListId, taskId, attachmentId)), + ); + + @override + Future uploadTaskFileAttachment({ + required String taskListId, + required String taskId, + required String name, + required String contentType, + required List bytes, + MicrosoftAttachmentUploadSession? resumeSession, + void Function(MicrosoftAttachmentUploadSession)? onSession, + }) async { + if (name.trim().isEmpty || name.contains('/') || name.contains('\\')) { + throw ArgumentError.value(name, 'name', 'Invalid attachment name.'); + } + if (bytes.length > 25 * 1024 * 1024) { + throw ArgumentError.value( + bytes.length, + 'bytes', + 'Task file exceeds 25 MB.', + ); + } + if (bytes.length < 3 * 1024 * 1024 && resumeSession == null) { + final created = await createSmallTaskAttachment( + taskListId: taskListId, + taskId: taskId, + name: name, + contentType: contentType, + bytes: bytes, + ); + if (created.id.isEmpty) { + throw const MicrosoftAttachmentUploadUncertain(); + } + return created.id; + } + MicrosoftAttachmentUploadSession session; + if (resumeSession == null) { + final created = await _requestJson( + 'POST', + _uri( + '${microsoftTaskAttachmentsPath(taskListId, taskId)}/createUploadSession', + ), + body: { + 'attachmentInfo': { + 'attachmentType': 'file', + 'name': name, + 'size': bytes.length, + }, + }, + ); + final uploadUrl = created['uploadUrl']?.toString(); + if (uploadUrl == null) { + throw const FormatException('Task attachment upload URL is missing.'); + } + session = MicrosoftAttachmentUploadSession( + url: _trustedNextLink(uploadUrl), + expiresAt: null, + nextOffset: 0, + ); + session.update(created, bytes.length); + onSession?.call(session); + } else { + session = resumeSession; + try { + session.update(await _requestJson('GET', session.url), bytes.length); + } on Object { + throw const MicrosoftAttachmentUploadUncertain(); + } + } + if (session.expiresAt case final expiry?) { + if (!expiry.isAfter(DateTime.now().toUtc())) { + throw const MicrosoftAttachmentUploadUncertain(); + } + } + // Task sessions are Graph-authenticated, unlike pre-authenticated Outlook + // event sessions. Never send a bearer token to a non-Graph authority. + final contentUri = session.url.replace(path: '${session.url.path}/content'); + var offset = session.nextOffset; + if (offset == bytes.length) { + throw const MicrosoftAttachmentUploadUncertain(); + } + const chunkSize = 2 * 1024 * 1024; + while (offset < bytes.length) { + final end = offset + chunkSize < bytes.length + ? offset + chunkSize + : bytes.length; + try { + if (end == bytes.length) { + session.finalRangeMayHaveBeenSubmitted = true; + } + final authorization = await _authorizationHeaderProvider?.call(); + final response = await _httpClient.put( + contentUri, + headers: { + if (authorization != null) 'Authorization': authorization, + 'Content-Type': 'application/octet-stream', + 'Content-Length': '${end - offset}', + 'Content-Range': 'bytes $offset-${end - 1}/${bytes.length}', + }, + body: bytes.sublist(offset, end), + ); + if (end == bytes.length) { + if (response.statusCode != 201) { + throw const MicrosoftAttachmentUploadUncertain(); + } + return attachmentIdFromUploadHeaders(response.headers); + } + if (response.statusCode != 200) { + throw const MicrosoftAttachmentUploadUncertain(); + } + session.update( + (jsonDecode(response.body) as Map).cast(), + bytes.length, + ); + if (session.nextOffset <= offset) { + throw const MicrosoftAttachmentUploadUncertain(); + } + } on Object { + throw const MicrosoftAttachmentUploadUncertain(); + } + offset = session.nextOffset; + } + throw const MicrosoftAttachmentUploadUncertain(); + } + + @override + Future cancelTaskAttachmentUpload( + MicrosoftAttachmentUploadSession session, + ) async { + try { + final response = await _send('DELETE', session.url); + if (response.statusCode != 204) { + throw const MicrosoftAttachmentUploadUncertain(); + } + } on Object { + throw const MicrosoftAttachmentUploadUncertain(); + } + } + @override Future updateTask({ required String taskListId, @@ -284,6 +561,7 @@ class MicrosoftTodoRestApiClient throw MicrosoftTodoApiError.fromResponse( statusCode: response.statusCode, body: response.body, + headers: response.headers, ); } } @@ -298,6 +576,7 @@ class MicrosoftTodoRestApiClient throw MicrosoftTodoApiError.fromResponse( statusCode: response.statusCode, body: response.body, + headers: response.headers, ); } return microsoftJsonObjectFromBody(response.body); @@ -380,6 +659,21 @@ class MicrosoftTodoRestApiClient return _uri(path); } + Uri _trustedNextLink(String value) { + final uri = Uri.tryParse(value); + if (uri == null || + uri.scheme != _baseUri.scheme || + uri.host != _baseUri.host || + uri.port != _baseUri.port || + uri.userInfo.isNotEmpty || + !uri.path.startsWith( + _baseUri.path.endsWith('/') ? _baseUri.path : '${_baseUri.path}/', + )) { + throw FormatException('Untrusted Microsoft Graph continuation URL.'); + } + return uri; + } + Uri _uri(String path, {Map? query}) { final basePath = _baseUri.path.endsWith('/') ? _baseUri.path.substring(0, _baseUri.path.length - 1) diff --git a/lib/src/microsoft_todo/api/microsoft_todo_api_error.dart b/lib/src/microsoft_todo/api/microsoft_todo_api_error.dart index 06c849be..7a9db61c 100644 --- a/lib/src/microsoft_todo/api/microsoft_todo_api_error.dart +++ b/lib/src/microsoft_todo/api/microsoft_todo_api_error.dart @@ -1,6 +1,7 @@ import 'dart:convert'; import '../../core/logging/redacting_logger.dart'; +import '../../core/http/retry_after.dart'; class MicrosoftTodoApiError implements Exception { const MicrosoftTodoApiError({ @@ -8,16 +9,20 @@ class MicrosoftTodoApiError implements Exception { required this.message, this.code, this.rawJson, + this.retryAfter, }); factory MicrosoftTodoApiError.fromResponse({ required int statusCode, required String body, + Map headers = const {}, }) { + final retryAfter = parseHttpRetryAfter(headers['retry-after']); if (body.trim().isEmpty) { return MicrosoftTodoApiError( statusCode: statusCode, message: 'Microsoft Graph returned HTTP $statusCode.', + retryAfter: retryAfter, ); } @@ -35,24 +40,28 @@ class MicrosoftTodoApiError implements Exception { ? 'Microsoft Graph returned HTTP $statusCode.' : redactForLog(errorJson['message']), rawJson: json, + retryAfter: retryAfter, ); } return MicrosoftTodoApiError( statusCode: statusCode, message: 'Microsoft Graph returned HTTP $statusCode.', rawJson: json, + retryAfter: retryAfter, ); } } on FormatException { return MicrosoftTodoApiError( statusCode: statusCode, message: redactForLog(body), + retryAfter: retryAfter, ); } return MicrosoftTodoApiError( statusCode: statusCode, message: redactForLog(body), + retryAfter: retryAfter, ); } @@ -60,6 +69,7 @@ class MicrosoftTodoApiError implements Exception { final String? code; final String message; final Map? rawJson; + final Duration? retryAfter; @override String toString() => 'MicrosoftTodoApiError($statusCode, $message)'; diff --git a/lib/src/microsoft_todo/api/microsoft_todo_api_models.dart b/lib/src/microsoft_todo/api/microsoft_todo_api_models.dart index 59d0e7c0..59d99863 100644 --- a/lib/src/microsoft_todo/api/microsoft_todo_api_models.dart +++ b/lib/src/microsoft_todo/api/microsoft_todo_api_models.dart @@ -180,6 +180,110 @@ class MicrosoftTodoTaskDto { final String? removedReason; } +class MicrosoftTodoLinkedResourceDto { + const MicrosoftTodoLinkedResourceDto({ + required this.id, + required this.applicationName, + required this.displayName, + required this.webUrl, + }); + + factory MicrosoftTodoLinkedResourceDto.fromJson(Map json) => + MicrosoftTodoLinkedResourceDto( + id: json['id']?.toString() ?? '', + applicationName: microsoftStringOrNull(json['applicationName']), + displayName: microsoftStringOrNull(json['displayName']), + webUrl: microsoftStringOrNull(json['webUrl']), + ); + + final String id; + final String? applicationName; + final String? displayName; + final String? webUrl; +} + +class MicrosoftTodoLinkedResourcesPageDto { + const MicrosoftTodoLinkedResourcesPageDto({ + required this.resources, + this.nextLink, + }); + + factory MicrosoftTodoLinkedResourcesPageDto.fromJson( + Map json, + ) { + final value = json['value']; + if (value is! List || value.any((item) => item is! Map)) { + throw const FormatException('Malformed Microsoft linked-resource list.'); + } + return MicrosoftTodoLinkedResourcesPageDto( + resources: [ + for (final item in value) + MicrosoftTodoLinkedResourceDto.fromJson( + (item as Map).cast(), + ), + ], + nextLink: microsoftStringOrNull(json['@odata.nextLink']), + ); + } + + final List resources; + final String? nextLink; +} + +class MicrosoftTodoAttachmentDto { + const MicrosoftTodoAttachmentDto({ + required this.id, + required this.name, + required this.kind, + this.size, + this.contentType, + }); + + factory MicrosoftTodoAttachmentDto.fromJson(Map json) => + MicrosoftTodoAttachmentDto( + id: json['id']?.toString() ?? '', + name: json['name']?.toString() ?? '', + kind: json['@odata.type']?.toString() ?? '', + size: json['size'] is int ? json['size'] as int : null, + contentType: microsoftStringOrNull(json['contentType']), + ); + + final String id; + final String name; + final String kind; + final int? size; + final String? contentType; + + bool get isFile => + kind.toLowerCase() == '#microsoft.graph.taskfileattachment'; +} + +class MicrosoftTodoAttachmentsPageDto { + const MicrosoftTodoAttachmentsPageDto({ + required this.attachments, + this.nextLink, + }); + + factory MicrosoftTodoAttachmentsPageDto.fromJson(Map json) { + final value = json['value']; + if (value is! List || value.any((item) => item is! Map)) { + throw const FormatException('Malformed Microsoft task attachment list.'); + } + return MicrosoftTodoAttachmentsPageDto( + attachments: [ + for (final item in value) + MicrosoftTodoAttachmentDto.fromJson( + (item as Map).cast(), + ), + ], + nextLink: microsoftStringOrNull(json['@odata.nextLink']), + ); + } + + final List attachments; + final String? nextLink; +} + class MicrosoftTodoChecklistItemDto { const MicrosoftTodoChecklistItemDto({ required this.id, diff --git a/lib/src/microsoft_todo/api/microsoft_todo_paths.dart b/lib/src/microsoft_todo/api/microsoft_todo_paths.dart index 684712a9..17fed3f3 100644 --- a/lib/src/microsoft_todo/api/microsoft_todo_paths.dart +++ b/lib/src/microsoft_todo/api/microsoft_todo_paths.dart @@ -20,6 +20,21 @@ String microsoftTaskPath(String taskListId, String taskId) { return '${microsoftTasksPath(taskListId)}/${Uri.encodeComponent(taskId)}'; } +String microsoftLinkedResourcesPath(String taskListId, String taskId) { + return '${microsoftTaskPath(taskListId, taskId)}/linkedResources'; +} + +String microsoftTaskAttachmentsPath(String taskListId, String taskId) => + '${microsoftTaskPath(taskListId, taskId)}/attachments'; + +String microsoftTaskAttachmentPath( + String taskListId, + String taskId, + String attachmentId, +) => + '${microsoftTaskAttachmentsPath(taskListId, taskId)}/' + '${Uri.encodeComponent(attachmentId)}'; + String microsoftChecklistItemsPath(String taskListId, String taskId) { return '${microsoftTaskPath(taskListId, taskId)}/checklistItems'; } diff --git a/lib/src/microsoft_todo/oauth/microsoft_oauth_service.dart b/lib/src/microsoft_todo/oauth/microsoft_oauth_service.dart index d4f2000e..67981ed6 100644 --- a/lib/src/microsoft_todo/oauth/microsoft_oauth_service.dart +++ b/lib/src/microsoft_todo/oauth/microsoft_oauth_service.dart @@ -5,6 +5,7 @@ import 'package:logging/logging.dart'; import '../../config/build_config.dart'; import '../../core/logging/redacting_logger.dart'; +import '../../core/auth/microsoft_graph_scopes.dart'; import '../../google_tasks/oauth/oauth_loopback_flow.dart'; import '../../providers/busy_provider.dart'; import 'package:busymax/src/core/auth/oauth_models.dart'; @@ -18,6 +19,20 @@ const microsoftTodoOAuthScopes = 'https://graph.microsoft.com/Tasks.ReadWrite ' 'https://graph.microsoft.com/Calendars.ReadWrite'; +const microsoftSharedCalendarScope = + 'https://graph.microsoft.com/Calendars.ReadWrite.Shared'; + +const microsoftCategoryScope = + 'https://graph.microsoft.com/MailboxSettings.Read'; + +abstract interface class MicrosoftCategoryAuthorization { + Future authorizeCategoryAccess(String accountId); +} + +abstract interface class MicrosoftSharedCalendarAuthorization { + Future authorizeSharedCalendarAccess(String accountId); +} + const microsoftSignInCallbackNotReceivedMessage = 'Microsoft sign-in callback was not received by BusyMax. Try signing in ' 'again. If the browser opened an old tab, close it and start sign-in ' @@ -31,7 +46,11 @@ abstract interface class MicrosoftOAuthGateway { Future signOutAccount(String accountId); } -class MicrosoftOAuthService implements MicrosoftOAuthGateway { +class MicrosoftOAuthService + implements + MicrosoftOAuthGateway, + MicrosoftSharedCalendarAuthorization, + MicrosoftCategoryAuthorization { MicrosoftOAuthService({ required BuildConfig config, required http.Client httpClient, @@ -104,6 +123,7 @@ class MicrosoftOAuthService implements MicrosoftOAuthGateway { accountId: accountId, tokenSet: tokenSet, user: user, + tenantId: microsoftTenantIdFromIdToken(tokenSet.idToken), ); } @@ -136,10 +156,187 @@ class MicrosoftOAuthService implements MicrosoftOAuthGateway { return 'Bearer ${tokenSet.accessToken}'; } + Future sharedCalendarAuthorizationHeaderForAccount( + String accountId, + ) async { + final tokenSet = await validTokenForAccount(accountId); + if (!hasMicrosoftGraphScope( + tokenSet.scopes, + microsoftSharedCalendarScope, + )) { + throw const OAuthException( + 'MicrosoftOAuthSharedConsentRequired', + 'Shared-calendar permission must be granted for this account.', + ); + } + return 'Bearer ${tokenSet.accessToken}'; + } + + Future categoryAuthorizationHeaderForAccount(String accountId) async { + final tokenSet = await validTokenForAccount(accountId); + if (!hasMicrosoftGraphScope(tokenSet.scopes, microsoftCategoryScope)) { + throw const OAuthException( + 'MicrosoftOAuthCategoryConsentRequired', + 'Outlook category lookup requires optional mailbox-settings consent.', + ); + } + return 'Bearer ${tokenSet.accessToken}'; + } + + @override + Future authorizeCategoryAccess(String accountId) async { + final generation = _credentialGenerations[accountId] ?? 0; + final existing = await _readTokenSet(accountId); + if (existing == null) { + throw const OAuthException( + 'MicrosoftOAuthMissingToken', + 'Reconnect this Microsoft account before loading categories.', + ); + } + if (hasMicrosoftGraphScope(existing.scopes, microsoftCategoryScope)) return; + final result = await _loopbackFlow.start( + authorizationEndpoint: _authorizationEndpoint, + clientId: _config.microsoftOAuthClientId.trim(), + scope: [ + microsoftTodoOAuthScopes, + if (hasMicrosoftGraphScope( + existing.scopes, + microsoftSharedCalendarScope, + )) + microsoftSharedCalendarScope, + microsoftCategoryScope, + ].join(' '), + redirectHost: 'localhost', + signInCancelledMessage: 'Microsoft category consent was cancelled.', + callbackNotReceivedMessage: microsoftSignInCallbackNotReceivedMessage, + serverStartFailureMessage: + 'Could not start the Microsoft sign-in callback listener.', + browserLaunchFailureMessage: + 'Could not open the browser for Microsoft sign-in.', + extraAuthorizationParameters: const { + 'response_mode': 'query', + 'prompt': 'consent', + }, + ); + final candidate = await exchangeAuthorizationCode( + code: result.callback.code, + codeVerifier: result.codeVerifier, + redirectUri: result.redirectUri, + fallbackScopeText: '', + existingRefreshToken: existing.refreshToken, + existingIdToken: existing.idToken, + ); + final user = await _getMe(candidate); + if ('microsoft:${user.id}' != accountId || + !hasMicrosoftGraphScope(candidate.scopes, microsoftCategoryScope) || + !hasMicrosoftGraphScopes(candidate.scopes, { + 'https://graph.microsoft.com/User.Read', + 'https://graph.microsoft.com/Tasks.ReadWrite', + 'https://graph.microsoft.com/Calendars.ReadWrite', + if (hasMicrosoftGraphScope( + existing.scopes, + microsoftSharedCalendarScope, + )) + microsoftSharedCalendarScope, + })) { + throw const OAuthException( + 'MicrosoftOAuthCategoryConsentDenied', + 'Outlook category access was not granted for this account.', + ); + } + if ((_credentialGenerations[accountId] ?? 0) != generation || + await _readTokenSet(accountId) == null) { + throw const OAuthException( + 'MicrosoftOAuthCategoryConsentCancelled', + 'The account was removed before category consent completed.', + ); + } + await _tokenStore.saveOAuthTokenSet( + accountId, + BusyProvider.microsoft, + candidate, + ); + } + + @override + Future authorizeSharedCalendarAccess(String accountId) async { + final generation = _credentialGenerations[accountId] ?? 0; + final existing = await _readTokenSet(accountId); + if (existing == null) { + throw const OAuthException( + 'MicrosoftOAuthMissingToken', + 'Reconnect this Microsoft account before opening a shared calendar.', + ); + } + if (hasMicrosoftGraphScope(existing.scopes, microsoftSharedCalendarScope)) { + return; + } + final result = await _loopbackFlow.start( + authorizationEndpoint: _authorizationEndpoint, + clientId: _config.microsoftOAuthClientId.trim(), + scope: [ + microsoftTodoOAuthScopes, + microsoftSharedCalendarScope, + if (hasMicrosoftGraphScope(existing.scopes, microsoftCategoryScope)) + microsoftCategoryScope, + ].join(' '), + redirectHost: 'localhost', + signInCancelledMessage: + 'Microsoft shared-calendar consent was cancelled.', + callbackNotReceivedMessage: microsoftSignInCallbackNotReceivedMessage, + serverStartFailureMessage: + 'Could not start the Microsoft sign-in callback listener.', + browserLaunchFailureMessage: + 'Could not open the browser for Microsoft sign-in.', + extraAuthorizationParameters: const { + 'response_mode': 'query', + 'prompt': 'consent', + }, + ); + final candidate = await exchangeAuthorizationCode( + code: result.callback.code, + codeVerifier: result.codeVerifier, + redirectUri: result.redirectUri, + fallbackScopeText: '', + existingRefreshToken: existing.refreshToken, + existingIdToken: existing.idToken, + ); + final user = await _getMe(candidate); + if ('microsoft:${user.id}' != accountId || + !hasMicrosoftGraphScopes(candidate.scopes, { + microsoftSharedCalendarScope, + 'https://graph.microsoft.com/User.Read', + 'https://graph.microsoft.com/Tasks.ReadWrite', + 'https://graph.microsoft.com/Calendars.ReadWrite', + if (hasMicrosoftGraphScope(existing.scopes, microsoftCategoryScope)) + microsoftCategoryScope, + })) { + throw const OAuthException( + 'MicrosoftOAuthSharedConsentDenied', + 'Shared-calendar access was not granted for this account.', + ); + } + if ((_credentialGenerations[accountId] ?? 0) != generation || + await _readTokenSet(accountId) == null) { + throw const OAuthException( + 'MicrosoftOAuthSharedConsentCancelled', + 'The account was removed before shared-calendar consent completed.', + ); + } + await _tokenStore.saveOAuthTokenSet( + accountId, + BusyProvider.microsoft, + candidate, + ); + } + Future exchangeAuthorizationCode({ required String code, required String codeVerifier, required String redirectUri, + String fallbackScopeText = microsoftTodoOAuthScopes, + String? existingRefreshToken, + String? existingIdToken, }) async { final clientId = _config.microsoftOAuthClientId.trim(); _validateTokenExchangeParameters( @@ -182,7 +379,9 @@ class MicrosoftOAuthService implements MicrosoftOAuthGateway { return OAuthTokenSet.fromTokenEndpointJson( json, issuedAtUtc: _nowUtc(), - fallbackScopeText: microsoftTodoOAuthScopes, + fallbackScopeText: fallbackScopeText, + existingRefreshToken: existingRefreshToken, + existingIdToken: existingIdToken, ); } @@ -234,7 +433,16 @@ class MicrosoftOAuthService implements MicrosoftOAuthGateway { 'client_id': clientId, 'grant_type': 'refresh_token', 'refresh_token': current.refreshToken!, - 'scope': microsoftTodoOAuthScopes, + 'scope': [ + microsoftTodoOAuthScopes, + if (hasMicrosoftGraphScope( + current.scopes, + microsoftSharedCalendarScope, + )) + microsoftSharedCalendarScope, + if (hasMicrosoftGraphScope(current.scopes, microsoftCategoryScope)) + microsoftCategoryScope, + ].join(' '), }, ); if (response.statusCode < 200 || response.statusCode >= 300) { @@ -305,11 +513,36 @@ class MicrosoftOAuthSignInResult { required this.accountId, required this.tokenSet, required this.user, + this.tenantId, }); final String accountId; final OAuthTokenSet tokenSet; final MicrosoftTodoUserDto user; + final String? tenantId; +} + +/// ID tokens are issued to this client; Graph access tokens are opaque and +/// must never be decoded to infer account capabilities. +String? microsoftTenantIdFromIdToken(String? idToken) { + if (idToken == null) return null; + final parts = idToken.split('.'); + if (parts.length != 3) return null; + try { + final decoded = jsonDecode( + utf8.decode(base64Url.decode(base64Url.normalize(parts[1]))), + ); + if (decoded is! Map) return null; + final tenantId = decoded['tid']?.toString().toLowerCase(); + return tenantId != null && + RegExp( + r'^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$', + ).hasMatch(tenantId) + ? tenantId + : null; + } on Object { + return null; + } } void _validateTokenExchangeParameters({ diff --git a/lib/src/schedule/event_attachment_link.dart b/lib/src/schedule/event_attachment_link.dart new file mode 100644 index 00000000..a2a179c0 --- /dev/null +++ b/lib/src/schedule/event_attachment_link.dart @@ -0,0 +1,49 @@ +final class EventAttachmentLink { + const EventAttachmentLink({ + required this.url, + required this.name, + this.mimeType, + }); + + final String url; + final String name; + final String? mimeType; +} + +/// Only externally openable references are projected. Binary iCalendar +/// attachments and malformed links remain in the original provider resource. +List eventAttachmentLinks(Object? value) { + if (value is! List) return const []; + final result = []; + final seen = {}; + for (final item in value) { + final map = item is Map ? item : const {}; + final rawUrl = item is String + ? item + : map['fileUrl']?.toString() ?? map['url']?.toString() ?? ''; + final uri = Uri.tryParse(rawUrl.trim()); + if (uri == null || + (uri.scheme != 'https' && uri.scheme != 'http') || + uri.host.isEmpty || + uri.userInfo.isNotEmpty || + !seen.add(uri.toString())) { + continue; + } + final title = map['title']?.toString().trim(); + final pathName = uri.pathSegments + .where((part) => part.isNotEmpty) + .lastOrNull; + result.add( + EventAttachmentLink( + url: uri.toString(), + name: title != null && title.isNotEmpty + ? title + : pathName == null || pathName.isEmpty + ? uri.host + : pathName, + mimeType: map['mimeType']?.toString(), + ), + ); + } + return result; +} diff --git a/lib/src/schedule/schedule_item.dart b/lib/src/schedule/schedule_item.dart index a7346285..08a36fc2 100644 --- a/lib/src/schedule/schedule_item.dart +++ b/lib/src/schedule/schedule_item.dart @@ -1,7 +1,9 @@ import 'package:busymax/src/providers/busy_provider.dart'; import '../features/calendar/domain/event_timing_policy.dart'; import '../features/tasks/domain/task_checklist_item.dart'; +import '../features/tasks/domain/task_source_links.dart'; import '../features/maps/domain/geographic_point.dart'; +import 'event_attachment_link.dart'; enum ScheduleItemKind { calendarEvent, task, localReminder } @@ -48,10 +50,13 @@ class CalendarScheduleItem implements ScheduleItem { required this.provider, required this.sourceId, required this.providerCalendarId, + this.providerEventId, required this.title, required this.allDay, this.start, this.providerRecurringEventId, + this.eventType, + this.googleStatusProperties = const {}, this.timingBaseline, this.end, this.location, @@ -62,6 +67,12 @@ class CalendarScheduleItem implements ScheduleItem { this.attendees = const [], this.organizer, this.joinMeetingUrl, + this.eventLinkUrl, + this.attachmentLinks = const [], + this.attachmentsLoaded = false, + this.attachmentsMayExist = false, + this.startTimeZone, + this.endTimeZone, this.isOrganizer, this.guestsCanModify, this.locked = false, @@ -87,7 +98,10 @@ class CalendarScheduleItem implements ScheduleItem { @override final String sourceId; final String providerCalendarId; + final String? providerEventId; final String? providerRecurringEventId; + final String? eventType; + final Map googleStatusProperties; final EventTimingBaseline? timingBaseline; bool get canReschedule => @@ -121,6 +135,12 @@ class CalendarScheduleItem implements ScheduleItem { final List> attendees; final Map? organizer; final String? joinMeetingUrl; + final String? eventLinkUrl; + final List attachmentLinks; + final bool attachmentsLoaded; + final bool attachmentsMayExist; + final String? startTimeZone; + final String? endTimeZone; final bool? isOrganizer; final bool? guestsCanModify; final bool locked; @@ -173,6 +193,9 @@ class TaskScheduleItem implements ScheduleItem { this.due, this.end, this.notes, + this.isAssigned = false, + this.originalTaskUrl, + this.sourceLinks = const [], this.location, this.locationPoint, this.categories = const [], @@ -205,6 +228,15 @@ class TaskScheduleItem implements ScheduleItem { @override final bool allDay; final bool completed; + final bool isAssigned; + final String? originalTaskUrl; + final List sourceLinks; + + List get availableSourceLinks => sourceLinks.isNotEmpty + ? sourceLinks + : originalTaskUrl == null + ? const [] + : [TaskSourceLink(url: originalTaskUrl!)]; /// Actual provider due value, independent of the scheduling start. final DateTime? due; diff --git a/lib/src/schedule/schedule_repository.dart b/lib/src/schedule/schedule_repository.dart index b67a4295..bcc6cd6a 100644 --- a/lib/src/schedule/schedule_repository.dart +++ b/lib/src/schedule/schedule_repository.dart @@ -1,4 +1,5 @@ import 'dart:convert'; +import 'dart:async'; import 'package:drift/drift.dart'; import 'package:busymax/src/providers/busy_provider.dart'; @@ -7,15 +8,20 @@ import '../core/time/provider_date_time.dart'; import '../features/maps/domain/geographic_point.dart'; import '../calendar_providers/calendar_colors.dart'; import '../calendar_providers/calendar_description.dart'; +import '../microsoft_calendar/microsoft_shared_calendar_address.dart'; import '../core/time/stored_temporal_projection.dart'; import '../dav/storage/dav_collection_capabilities.dart'; import '../db/app_database.dart'; import '../features/accounts/data/accounts_repository.dart'; import '../features/calendar/data/calendar_event_detail.dart'; import '../features/calendar/domain/event_timing_policy.dart'; +import '../features/calendar/domain/google_status_event.dart'; import '../features/tasks/domain/task_checklist_item.dart'; +import '../features/tasks/domain/google_task_assignment_policy.dart'; +import '../features/tasks/domain/task_source_links.dart'; import 'schedule_filters.dart'; import 'schedule_item.dart'; +import 'event_attachment_link.dart'; import 'schedule_projection.dart'; import 'schedule_range.dart'; import 'schedule_sorting.dart'; @@ -23,33 +29,115 @@ import 'schedule_search_match.dart'; export 'schedule_search_match.dart' show matchesScheduleQuery; class ScheduleRepository { - const ScheduleRepository( + ScheduleRepository( this._database, { Future Function(ScheduleRange range)? ensureProjectionCoverage, - }) : _ensureProjectionCoverage = ensureProjectionCoverage; + Future Function(ScheduleRange range, ScheduleFilters filters)? + ensureCloudCoverage, + DateTime Function()? nowUtc, + }) : _ensureProjectionCoverage = ensureProjectionCoverage, + _ensureCloudCoverage = ensureCloudCoverage, + _nowUtc = nowUtc ?? (() => DateTime.now().toUtc()); final AppDatabase _database; final Future Function(ScheduleRange range)? _ensureProjectionCoverage; + final Future Function(ScheduleRange range, ScheduleFilters filters)? + _ensureCloudCoverage; + final DateTime Function() _nowUtc; + final Map _rangeCoverage = {}; + final Map _rangeCoverageGeneration = {}; + final Map> _coverageInFlight = {}; + final Map _coverageLastAttempt = {}; + final StreamController _coverageChanges = + StreamController.broadcast(); + + bool? cloudCoverageCompleteFor( + ScheduleRange range, { + ScheduleFilters filters = const ScheduleFilters(), + }) => _rangeCoverage[_coverageKey(range, filters)]; + + String _coverageKey(ScheduleRange range, ScheduleFilters filters) { + final accounts = filters.accountIds.toList()..sort(); + final sources = filters.sourceFilterActive + ? (filters.sourceIds.toList()..sort()) + : []; + return jsonEncode([ + range.start.toUtc().millisecondsSinceEpoch, + range.end.toUtc().millisecondsSinceEpoch, + accounts, + filters.sourceFilterActive, + sources, + ]); + } /// Invalidates presentation queries after committed local or sync writes. /// Include joined metadata as well as items: permissions, source names and /// account availability can change without an item's identity changing. - Stream watchChanges() => _database - .tableUpdates( - TableUpdateQuery.onAllTables([ - _database.accounts, - _database.tasks, - _database.taskLists, - _database.calendarEvents, - _database.calendarSources, - _database.calendarEventAttendees, - _database.calendarEventReminders, - _database.calendarColors, - _database.davCollections, - _database.scheduleItemOverrides, - ]), - ) - .map((_) {}); + Stream watchChanges() { + final databaseChanges = _database + .tableUpdates( + TableUpdateQuery.onAllTables([ + _database.accounts, + _database.tasks, + _database.taskLists, + _database.calendarEvents, + _database.calendarSources, + _database.calendarEventAttendees, + _database.calendarEventReminders, + _database.calendarColors, + _database.davCollections, + _database.scheduleItemOverrides, + ]), + ) + .map((_) {}); + return Stream.multi((controller) { + final databaseSubscription = databaseChanges.listen(controller.add); + final coverageSubscription = _coverageChanges.stream.listen( + controller.add, + ); + controller.onCancel = () async { + await databaseSubscription.cancel(); + await coverageSubscription.cancel(); + }; + }); + } + + void _requestCloudCoverage(ScheduleRange range, ScheduleFilters filters) { + final ensure = _ensureCloudCoverage; + if (ensure == null) return; + final key = _coverageKey(range, filters); + if (_coverageInFlight.containsKey(key)) return; + final now = _nowUtc(); + final previous = _coverageLastAttempt[key]; + // A failed online request must not suppress reconnect for the entire + // successful-range freshness window. Bound retries to avoid an offline + // view triggering a network request on every rebuild. + final retryInterval = _rangeCoverage[key] == false + ? const Duration(seconds: 10) + : const Duration(minutes: 5); + if (previous != null && now.difference(previous) < retryInterval) { + return; + } + _coverageLastAttempt[key] = now; + final generation = (_rangeCoverageGeneration[key] ?? 0) + 1; + _rangeCoverageGeneration[key] = generation; + _rangeCoverage.remove(key); + _coverageChanges.add(null); + final pending = Future.sync(() async { + bool complete; + try { + complete = await ensure(range, filters); + } on Object { + complete = false; + } + if (_rangeCoverageGeneration[key] == generation) { + _rangeCoverage[key] = complete; + _coverageChanges.add(null); + } + }); + _coverageInFlight[key] = pending; + unawaited(pending.whenComplete(() => _coverageInFlight.remove(key))); + } Future findTaskTarget({ required String accountId, @@ -132,6 +220,7 @@ class ScheduleRepository { ScheduleFilters filters = const ScheduleFilters(), }) async { if (filters.includeCalendarEvents && !filters.ignoreDateRange) { + _requestCloudCoverage(range, filters); await _ensureProjectionCoverage?.call(range); } final context = await _accountContext(filters); @@ -444,6 +533,17 @@ class ScheduleRepository { final organizer = _jsonMapFromString(event.organizerJson); final conference = _jsonValueFromString(event.conferenceJson); final raw = _jsonMapFromString(event.rawJson) ?? const {}; + final sourceRaw = _jsonMapFromString(source?.rawJson) ?? const {}; + final restrictedPrivate = + provider == BusyProvider.microsoft && + source != null && + MicrosoftSharedPrimaryCalendarAddress.parse( + source.providerCalendarId, + ) != + null && + (sourceRaw['_busymaxOwnerAccessUnavailable'] == true || + (event.visibility?.toLowerCase() == 'private' && + sourceRaw['canViewPrivateItems'] != true)); final isOrganizer = _eventIsOrganizer(provider, organizer, raw); final sourceWritable = source != null && !source.readOnly && !source.isDeleted; @@ -465,23 +565,47 @@ class ScheduleRepository { provider: provider, sourceId: event.calendarSourceId, providerCalendarId: event.providerCalendarId, + providerEventId: event.providerEventId, providerRecurringEventId: detail.recurringMutationSeriesId, + eventType: restrictedPrivate ? null : event.eventType, + googleStatusProperties: restrictedPrivate + ? const {} + : googleStatusPropertiesFromRaw(event.eventType, raw), timingBaseline: EventTimingBaseline.fromDetail(detail), - title: event.title, + title: restrictedPrivate ? '•••' : event.title, allDay: event.allDay, start: start, end: end, - location: event.location, - locationPoint: GeographicPoint.tryParse( - latitude: event.locationLatitude, - longitude: event.locationLongitude, - ), - description: event.description, - descriptionContentType: descriptionBody.contentType, - descriptionHtml: descriptionBody.html, - attendees: attendees, - organizer: organizer, - joinMeetingUrl: _eventJoinMeetingUrl(provider, conference, raw), + location: restrictedPrivate ? null : event.location, + locationPoint: restrictedPrivate + ? null + : GeographicPoint.tryParse( + latitude: event.locationLatitude, + longitude: event.locationLongitude, + ), + description: restrictedPrivate ? null : event.description, + descriptionContentType: restrictedPrivate + ? null + : descriptionBody.contentType, + descriptionHtml: restrictedPrivate ? null : descriptionBody.html, + attendees: restrictedPrivate ? const [] : attendees, + organizer: restrictedPrivate ? null : organizer, + joinMeetingUrl: restrictedPrivate + ? null + : _eventJoinMeetingUrl(provider, conference, raw), + eventLinkUrl: restrictedPrivate + ? null + : (_isWebUrl(event.webLink) ? event.webLink : null), + attachmentLinks: restrictedPrivate + ? const [] + : eventAttachmentLinks(detail.attachments), + attachmentsLoaded: !restrictedPrivate && detail.attachments is List, + attachmentsMayExist: + !restrictedPrivate && + (raw['hasAttachments'] == true || + eventAttachmentLinks(detail.attachments).isNotEmpty), + startTimeZone: event.startTimeZone, + endTimeZone: event.endTimeZone, isOrganizer: isOrganizer, isFederated: provider == BusyProvider.nextcloud && @@ -499,12 +623,12 @@ class ScheduleRepository { ? raw['guestsCanModify'] == true : null, locked: provider == BusyProvider.google && raw['locked'] == true, - currentUserResponse: _eventCurrentUserResponse( - provider, - attendees, - raw, - ), - categories: _stringListFromJson(event.categoriesJson), + currentUserResponse: restrictedPrivate + ? null + : _eventCurrentUserResponse(provider, attendees, raw), + categories: restrictedPrivate + ? const [] + : _stringListFromJson(event.categoriesJson), reminderMinutesBeforeStart: _eventReminderMinutes( provider, event.remindersJson, @@ -522,6 +646,7 @@ class ScheduleRepository { accountEmail: accountEmails[event.accountId], capabilities: ScheduleItemCapabilities( canEdit: + !restrictedPrivate && (provider == BusyProvider.nextcloud ? davCapabilities?.canUpdateEvent == true && source?.isDeleted == false @@ -532,10 +657,12 @@ class ScheduleRepository { raw, attendees.isNotEmpty, ), - canDelete: provider == BusyProvider.nextcloud - ? davCapabilities?.canDeleteEvent == true && - source?.isDeleted == false - : sourceWritable, + canDelete: + !restrictedPrivate && + (provider == BusyProvider.nextcloud + ? davCapabilities?.canDeleteEvent == true && + source?.isDeleted == false + : sourceWritable), ), ), ); @@ -783,6 +910,23 @@ class ScheduleRepository { end: _taskEnd(task, provider), due: _taskDue(task, provider), notes: task.notes ?? task.bodyContent, + isAssigned: + provider == BusyProvider.google && + GoogleTaskAssignmentPolicy.fromJson( + task.assignmentInfoJson, + ).isAssigned, + originalTaskUrl: provider == BusyProvider.google + ? GoogleTaskAssignmentPolicy.fromJson( + task.assignmentInfoJson, + ).originalTaskUrl + : null, + sourceLinks: provider == BusyProvider.google + ? googleTaskSourceLinks( + assignmentInfoJson: task.assignmentInfoJson, + linksJson: task.linksJson, + webViewLink: task.webViewLink, + ) + : const [], location: task.taskLocation, locationPoint: GeographicPoint.tryParse( latitude: task.locationLatitude, @@ -1345,13 +1489,26 @@ String? _eventJoinMeetingUrl( final fallback = switch (provider) { BusyProvider.google => raw['hangoutLink']?.toString().trim(), BusyProvider.microsoft => raw['onlineMeetingUrl']?.toString().trim(), - BusyProvider.appleICloud || - BusyProvider.nextcloud || - BusyProvider.webCal => null, + BusyProvider.nextcloud => _nextcloudConferenceUrl(raw), + BusyProvider.appleICloud || BusyProvider.webCal => null, }; return _isWebUrl(fallback) ? fallback : null; } +String? _nextcloudConferenceUrl(Map raw) { + final links = raw['conferenceLinks']; + if (links is! List) return null; + for (final link in links.whereType()) { + final features = link['features']?.toString().toUpperCase() ?? ''; + final url = link['url']?.toString().trim(); + if ((features.contains('VIDEO') || features.contains('AUDIO')) && + _isWebUrl(url)) { + return url; + } + } + return null; +} + bool _isWebUrl(String? value) { if (value == null || value.isEmpty) return false; final uri = Uri.tryParse(value); diff --git a/lib/src/ui/windows/windows_cloud_availability_dialog.dart b/lib/src/ui/windows/windows_cloud_availability_dialog.dart new file mode 100644 index 00000000..ca91cb09 --- /dev/null +++ b/lib/src/ui/windows/windows_cloud_availability_dialog.dart @@ -0,0 +1,142 @@ +import 'dart:async'; + +import 'package:fluent_ui/fluent_ui.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 '../../features/calendar/presentation/event_editor_draft.dart'; +import '../../../l10n/generated/app_localizations.dart'; +import '../../l10n/time_format_scope.dart'; + +Future showWindowsCloudAvailabilityDialog( + BuildContext context, { + required EventEditorDraft draft, + String? calendarTimeZone, +}) => showDialog( + context: context, + barrierDismissible: false, + builder: (_) => _WindowsCloudAvailabilityDialog( + draft: draft, + calendarTimeZone: calendarTimeZone, + ), +); + +class _WindowsCloudAvailabilityDialog extends ConsumerStatefulWidget { + const _WindowsCloudAvailabilityDialog({ + required this.draft, + this.calendarTimeZone, + }); + + final EventEditorDraft draft; + final String? calendarTimeZone; + + @override + ConsumerState<_WindowsCloudAvailabilityDialog> createState() => + _WindowsCloudAvailabilityDialogState(); +} + +class _WindowsCloudAvailabilityDialogState + extends ConsumerState<_WindowsCloudAvailabilityDialog> { + bool _loading = true; + int _generation = 0; + Object? _error; + List _results = const []; + + @override + void initState() { + super.initState(); + unawaited(_load()); + } + + Future _load() async { + final generation = ++_generation; + setState(() { + _loading = true; + _error = null; + }); + try { + final client = ref.read( + calendarRemoteApiClientForAccountProvider(widget.draft.accountId), + ); + final interval = widget.draft.cloudAvailabilityInterval( + calendarTimeZone: widget.calendarTimeZone, + ); + if (client is! DetailedFreeBusyClient || interval == null) { + throw StateError('Availability is unavailable.'); + } + final recipients = widget.draft.attendees + .where((attendee) => !attendee.self && !attendee.organizer) + .map((attendee) => attendee.email.trim()) + .where((email) => email.isNotEmpty) + .toSet() + .toList(); + final fetched = await (client as DetailedFreeBusyClient).freeBusyDetails( + calendarIds: recipients, + rangeStart: interval.start.toUtc(), + rangeEnd: interval.end.toUtc(), + ); + if (mounted && generation == _generation) _results = fetched; + } on Object catch (error) { + if (mounted && generation == _generation) _error = error; + } finally { + if (mounted && generation == _generation) { + setState(() => _loading = false); + } + } + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + final date = DateFormat.yMMMd(Localizations.localeOf(context).toString()); + String interval(BusySlotDto slot) => + '${formatClockDateTime(context, slot.start.toLocal(), date.format(slot.start.toLocal()))} – ' + '${formatClockDateTime(context, slot.end.toLocal(), date.format(slot.end.toLocal()))}'; + return ContentDialog( + key: const ValueKey('windows-cloud-availability-dialog'), + title: Text(l10n.nextcloudGuestAvailability), + content: SizedBox( + width: 560, + height: 400, + child: _loading + ? const Center(child: ProgressRing()) + : _error != null + ? Text(redactForLog(_error)) + : ListView( + children: [ + for (final result in _results) + Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SelectableText(result.calendarId), + Text( + !result.succeeded + ? l10n.nextcloudAvailabilityUnknown + : result.busySlots.isEmpty + ? l10n.nextcloudAvailabilityFree + : result.busySlots.map(interval).join('\n'), + ), + if (result.errors.isNotEmpty) + Text(result.errors.join(' · ')), + ], + ), + ), + ], + ), + ), + actions: [ + Button(onPressed: _loading ? null : _load, child: Text(l10n.refresh)), + FilledButton( + onPressed: () => Navigator.pop(context), + child: Text(l10n.close), + ), + ], + ); + } +} diff --git a/lib/src/ui/windows/windows_cloud_calendar_sharing_dialog.dart b/lib/src/ui/windows/windows_cloud_calendar_sharing_dialog.dart new file mode 100644 index 00000000..d505b5f4 --- /dev/null +++ b/lib/src/ui/windows/windows_cloud_calendar_sharing_dialog.dart @@ -0,0 +1,396 @@ +import 'dart:async'; + +import 'package:fluent_ui/fluent_ui.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../app/app_bootstrap.dart'; +import '../../features/calendar/data/calendar_repository.dart'; +import '../../features/calendar/data/cloud_calendar_sharing_service.dart'; +import '../../features/calendar/presentation/cloud_calendar_sharing_labels.dart'; +import '../../../l10n/generated/app_localizations.dart'; +import '../../providers/busy_provider.dart'; + +Future showWindowsCloudCalendarSharingDialog( + BuildContext context, { + required List sources, + CloudCalendarSharingService Function(CalendarSourceEntity)? serviceFactory, +}) => showDialog( + context: context, + builder: (dialogContext) => ContentDialog( + title: Text(AppLocalizations.of(dialogContext).manageCalendarSharing), + content: SizedBox( + width: 540, + child: SingleChildScrollView( + child: _WindowsCloudCalendarSharingContent( + sources: sources, + serviceFactory: serviceFactory, + ), + ), + ), + actions: [ + Button( + onPressed: () => Navigator.pop(dialogContext), + child: Text(AppLocalizations.of(dialogContext).close), + ), + ], + ), +); + +class _WindowsCloudCalendarSharingContent extends ConsumerStatefulWidget { + const _WindowsCloudCalendarSharingContent({ + required this.sources, + this.serviceFactory, + }); + final List sources; + final CloudCalendarSharingService Function(CalendarSourceEntity)? + serviceFactory; + + @override + ConsumerState<_WindowsCloudCalendarSharingContent> createState() => + _WindowsCloudCalendarSharingContentState(); +} + +class _WindowsCloudCalendarSharingContentState + extends ConsumerState<_WindowsCloudCalendarSharingContent> { + final _recipient = TextEditingController(); + CloudCalendarSharingService? _service; + CloudCalendarShareSnapshot? _snapshot; + Object? _error; + bool _busy = false; + Timer? _cooldownTimer; + String? _role; + int _generation = 0; + + @override + void initState() { + super.initState(); + if (widget.sources.isNotEmpty) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) unawaited(_load(widget.sources.first)); + }); + } + } + + @override + void dispose() { + _generation++; + _cooldownTimer?.cancel(); + _recipient.dispose(); + super.dispose(); + } + + Future _load(CalendarSourceEntity source) async { + final generation = ++_generation; + _cooldownTimer?.cancel(); + setState(() { + _service = null; + _snapshot = null; + _error = null; + _busy = true; + }); + try { + final service = + widget.serviceFactory?.call(source) ?? + CloudCalendarSharingService( + source: source, + google: source.provider == BusyProvider.google + ? ref.read( + googleCalendarApiClientForAccountProvider(source.accountId), + ) + : null, + microsoft: source.provider == BusyProvider.microsoft + ? ref.read( + microsoftCalendarApiClientForAccountProvider( + source.accountId, + ), + ) + : null, + ); + if (!mounted || generation != _generation) return; + setState(() { + _service = service; + _role = service.newGrantRoles.first; + }); + final result = await service.load(); + if (!mounted || generation != _generation) return; + setState(() => _snapshot = result); + } on Object catch (error) { + if (!mounted || generation != _generation) return; + setState(() => _error = error); + } finally { + if (mounted && generation == _generation) { + setState(() => _busy = false); + _scheduleCooldown(); + } + } + } + + Future _mutate( + Future Function(CloudCalendarSharingService) + action, + ) async { + final service = _service; + if (service == null || + _busy || + _snapshot == null || + _snapshot!.refreshError != null || + _snapshot!.outcomeUnknown || + service.hasUnresolvedOutcome) { + return; + } + final generation = _generation; + setState(() { + _busy = true; + _error = null; + }); + try { + final result = await action(service); + if (!mounted || generation != _generation) return; + setState(() => _snapshot = result); + } on Object catch (error) { + if (!mounted || generation != _generation) return; + setState(() => _error = error); + } finally { + if (mounted && generation == _generation) { + setState(() => _busy = false); + _scheduleCooldown(); + } + } + } + + void _scheduleCooldown() { + _cooldownTimer?.cancel(); + final service = _service; + final delay = service?.retryAfter; + if (service == null || delay == null) return; + final generation = _generation; + _cooldownTimer = Timer(delay + const Duration(milliseconds: 1), () { + if (!mounted || + generation != _generation || + !identical(_service, service)) { + return; + } + setState(() {}); + _scheduleCooldown(); + }); + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + final service = _service; + final grants = _snapshot?.grants; + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (widget.sources.length > 1) + ComboBox( + key: const Key('windows-sharing-source'), + value: service?.source.id, + isExpanded: true, + items: [ + for (final source in widget.sources) + ComboBoxItem(value: source.id, child: Text(source.summary)), + ], + onChanged: _busy + ? null + : (id) { + if (id == null) return; + unawaited( + _load(widget.sources.firstWhere((e) => e.id == id)), + ); + }, + ), + if (_busy) const ProgressBar(), + if (_error != null) ...[ + InfoBar( + title: Text(l10n.operationFailed), + content: SelectableText('$_error'), + severity: InfoBarSeverity.error, + ), + Button( + onPressed: service == null || _busy + ? null + : () => unawaited(_load(service.source)), + child: Text(l10n.retry), + ), + ], + if (service?.isRateLimited == true) + InfoBar( + title: Text(l10n.sharingRateLimited), + severity: InfoBarSeverity.warning, + ), + if (_snapshot?.outcomeUnknown == true) + Column( + children: [ + InfoBar( + title: Text(l10n.nextcloudOutcomeUnknown), + severity: InfoBarSeverity.warning, + ), + Button( + onPressed: _busy || service == null + ? null + : () => unawaited(_load(service.source)), + child: Text(l10n.retry), + ), + ], + ) + else if (_snapshot?.refreshError != null) + Column( + children: [ + InfoBar( + title: Text(l10n.sharingRefreshFailed), + severity: InfoBarSeverity.warning, + ), + Button( + onPressed: _busy || service == null + ? null + : () => unawaited(_load(service.source)), + child: Text(l10n.retry), + ), + ], + ), + if (grants != null) ...[ + SizedBox( + height: 230, + child: ListView( + children: [ + for (final grant in grants) + Padding( + key: Key('windows-sharing-grant-${grant.id}'), + padding: const EdgeInsets.symmetric(vertical: 6), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(grant.recipient), + Text(calendarSharingRoleLabel(l10n, grant.role)), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + if (grant.canChange) + DropDownButton( + title: Text(l10n.shareRole), + items: [ + for (final role in grant.allowedRoles) + MenuFlyoutItem( + text: Text( + calendarSharingRoleLabel(l10n, role), + ), + onPressed: + _busy || + service?.isRateLimited == true || + _snapshot?.refreshError != null || + _snapshot?.outcomeUnknown == true + ? null + : () => unawaited( + _mutate( + (s) => s.change(grant, role), + ), + ), + ), + ], + ), + if (grant.canRevoke) + Button( + onPressed: + _busy || + service?.isRateLimited == true || + _snapshot?.refreshError != null || + _snapshot?.outcomeUnknown == true + ? null + : () => unawaited(_confirmRevoke(grant)), + child: Text(l10n.nextcloudRevokeShare), + ), + ], + ), + ], + ), + ), + ], + ), + ), + InfoLabel( + label: l10n.shareRecipientEmail, + child: TextBox( + key: const Key('windows-sharing-recipient'), + controller: _recipient, + enabled: !_busy && _snapshot?.outcomeUnknown != true, + ), + ), + const SizedBox(height: 8), + InfoLabel( + label: l10n.shareRole, + child: ComboBox( + key: const Key('windows-sharing-new-role'), + value: _role, + isExpanded: true, + items: [ + for (final role in service!.newGrantRoles) + ComboBoxItem( + value: role, + child: Text(calendarSharingRoleLabel(l10n, role)), + ), + ], + onChanged: _busy || _snapshot?.outcomeUnknown == true + ? null + : (value) => setState(() => _role = value), + ), + ), + const SizedBox(height: 8), + Align( + alignment: Alignment.centerRight, + child: Button( + key: const Key('windows-sharing-add'), + onPressed: + _busy || + service.isRateLimited || + _role == null || + _snapshot?.refreshError != null || + _snapshot?.outcomeUnknown == true + ? null + : () => unawaited(_add()), + child: Text(l10n.addCalendarShare), + ), + ), + ], + if (service == null && widget.sources.isEmpty) + Text(l10n.sharingPermissionUnavailable), + ], + ); + } + + Future _add() async { + final role = _role; + if (role == null) return; + await _mutate( + (service) => service.add(recipient: _recipient.text, role: role), + ); + if (_error == null && mounted) _recipient.clear(); + } + + Future _confirmRevoke(CloudCalendarShareGrant grant) async { + final l10n = AppLocalizations.of(context); + final confirmed = await showDialog( + context: context, + builder: (context) => ContentDialog( + title: Text(l10n.nextcloudRevokeShare), + content: Text(grant.recipient), + actions: [ + Button( + onPressed: () => Navigator.pop(context, false), + child: Text(l10n.cancel), + ), + FilledButton( + onPressed: () => Navigator.pop(context, true), + child: Text(l10n.nextcloudRevokeShare), + ), + ], + ), + ); + if (confirmed == true && mounted) { + await _mutate((service) => service.revoke(grant)); + } + } +} diff --git a/lib/src/ui/windows/windows_event_attachments_dialog.dart b/lib/src/ui/windows/windows_event_attachments_dialog.dart new file mode 100644 index 00000000..f746b81e --- /dev/null +++ b/lib/src/ui/windows/windows_event_attachments_dialog.dart @@ -0,0 +1,570 @@ +import 'dart:async'; + +import 'package:file_selector/file_selector.dart'; +import 'package:fluent_ui/fluent_ui.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../app/app_bootstrap.dart'; +import '../../features/schedule/presentation/attachment_download.dart'; +import '../../features/schedule/presentation/attachment_upload_coordinator.dart'; +import '../../features/schedule/presentation/schedule_event_details_format.dart'; +import '../../l10n/l10n.dart'; +import '../../microsoft_calendar/microsoft_event_attachment.dart'; +import '../../providers/busy_provider.dart'; +import '../../schedule/event_attachment_link.dart'; +import '../../schedule/schedule_item.dart'; +import '../../features/schedule/presentation/google_event_attachment_reference.dart'; + +Future showWindowsEventAttachmentsDialog( + BuildContext context, + CalendarScheduleItem item, +) => showDialog( + context: context, + builder: (_) => _WindowsEventAttachmentsDialog(item: item), +); + +class _WindowsEventAttachmentsDialog extends ConsumerStatefulWidget { + const _WindowsEventAttachmentsDialog({required this.item}); + final CalendarScheduleItem item; + + @override + ConsumerState<_WindowsEventAttachmentsDialog> createState() => + _WindowsEventAttachmentsDialogState(); +} + +class _WindowsEventAttachmentsDialogState + extends ConsumerState<_WindowsEventAttachmentsDialog> { + bool _saving = false; + String? _error; + late List _referenceLinks; + + @override + void initState() { + super.initState(); + _referenceLinks = List.of(widget.item.attachmentLinks); + } + + @override + Widget build(BuildContext context) { + final item = widget.item; + final eventId = item.providerEventId; + final microsoft = item.provider == BusyProvider.microsoft; + final google = item.provider == BusyProvider.google; + final nextcloud = item.provider == BusyProvider.nextcloud; + final uploads = ref.watch(attachmentUploadCoordinatorProvider); + final uploadKey = eventId == null + ? null + : AttachmentUploadCoordinator.eventKey( + item.accountId, + item.providerCalendarId, + eventId, + ); + final result = !microsoft || eventId == null + ? null + : ref.watch( + microsoftEventAttachmentsProvider(( + accountId: item.accountId, + calendarId: item.providerCalendarId, + eventId: eventId, + )), + ); + return ContentDialog( + title: Text(context.l10n.attachments), + content: SizedBox( + width: 480, + height: 320, + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (_error case final error?) Text(error), + if (microsoft && + uploadKey != null && + uploads.retryAfter(uploadKey) != null) + Text(context.l10n.attachmentUploadRateLimited), + if (microsoft && + uploadKey != null && + uploads.needsReconciliation(uploadKey)) ...[ + Text( + uploads.confirmedId(uploadKey) == null + ? context.l10n.attachmentUploadUnresolved + : context.l10n.completed, + ), + Button( + onPressed: _saving + ? null + : () => unawaited(_reconcileUpload(eventId!)), + child: Text(context.l10n.refresh), + ), + if (uploads.hasResumableSession(uploadKey)) + Button( + onPressed: _saving + ? null + : () => unawaited(_cancelUpload(eventId!)), + child: Text(context.l10n.cancel), + ), + if (uploads.canResolveManually(uploadKey)) ...[ + Button( + onPressed: _saving + ? null + : () => unawaited( + _resolveUpload(uploadKey, exists: true), + ), + child: Text(context.l10n.completed), + ), + Button( + onPressed: _saving + ? null + : () => unawaited( + _resolveUpload(uploadKey, exists: false), + ), + child: Text(context.l10n.retry), + ), + ], + ], + if (!microsoft) + if (_referenceLinks.isEmpty) + Text( + item.attachmentsLoaded + ? context.l10n.noneValue + : context.l10n.attachmentsNotLoaded, + ) + else + for (final link in _referenceLinks) + Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(link.name), + Wrap( + spacing: 8, + children: [ + Button( + onPressed: () => unawaited(_open(link.url)), + child: Text(context.l10n.openInProvider), + ), + if ((google || nextcloud) && + item.capabilities.canEdit) + Button( + key: const Key( + 'event-attachment-remove-reference', + ), + onPressed: _saving + ? null + : () => unawaited(_removeReference(link)), + child: Text(context.l10n.delete), + ), + ], + ), + ], + ), + ) + else if (result == null) + Text(context.l10n.attachmentsNotLoaded) + else + result.when( + loading: () => const ProgressRing(), + error: (error, _) => SelectableText( + '${context.l10n.attachmentsNotLoaded}\n$error', + ), + data: (attachments) => attachments.isEmpty + ? Text(context.l10n.noneValue) + : Column( + children: [ + for (final attachment in attachments) + Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + Text(attachment.name), + if (attachment.size case final size?) + Text('$size B'), + ], + ), + ), + if (attachment.canDownload) + Button( + onPressed: _saving + ? null + : () => + unawaited(_save(attachment)), + child: Text(context.l10n.save), + ) + else if (attachment.kind == + MicrosoftEventAttachmentKind + .reference && + attachment.sourceUrl != null) + Button( + onPressed: () => unawaited( + _open(attachment.sourceUrl!), + ), + child: Text( + context.l10n.openInProvider, + ), + ), + if (item.capabilities.canEdit && + attachment.id.isNotEmpty) + Button( + onPressed: _saving + ? null + : () => unawaited( + _remove(attachment), + ), + child: Text(context.l10n.delete), + ), + ], + ), + ), + ], + ), + ), + ], + ), + ), + ), + actions: [ + if ((microsoft || google || nextcloud) && + item.capabilities.canEdit && + (nextcloud || eventId != null)) + Button( + key: const Key('event-attachment-add'), + onPressed: + _saving || + (microsoft && + uploadKey != null && + !uploads.canSubmit(uploadKey)) + ? null + : () => unawaited(_add()), + child: Tooltip( + message: context.l10n.attachments, + child: const Icon(FluentIcons.add), + ), + ), + Button( + onPressed: _saving ? null : () => Navigator.pop(context), + child: Text(context.l10n.close), + ), + ], + ); + } + + void _refresh() { + final eventId = widget.item.providerEventId; + if (eventId == null) return; + ref.invalidate( + microsoftEventAttachmentsProvider(( + accountId: widget.item.accountId, + calendarId: widget.item.providerCalendarId, + eventId: eventId, + )), + ); + } + + Future _add() async { + final eventId = widget.item.providerEventId; + if (!widget.item.capabilities.canEdit) return; + if (widget.item.provider == BusyProvider.google || + widget.item.provider == BusyProvider.nextcloud) { + var enteredUrl = ''; + final url = await showDialog( + context: context, + builder: (dialogContext) => ContentDialog( + title: Text(context.l10n.attachments), + content: InfoLabel( + label: context.l10n.webLink, + child: TextBox( + onChanged: (value) => enteredUrl = value, + autofocus: true, + ), + ), + actions: [ + Button( + onPressed: () => Navigator.pop(dialogContext), + child: Text(context.l10n.cancel), + ), + FilledButton( + onPressed: () => Navigator.pop(dialogContext, enteredUrl), + child: Text(context.l10n.save), + ), + ], + ), + ); + if (url == null || !mounted) return; + await _changeReference(addFileUrl: url); + return; + } + if (eventId == null) return; + final file = await openFile(); + if (file == null || !mounted) return; + final name = safeAttachmentFileName(file.name); + if (name == null) { + setState(() => _error = context.l10n.operationFailed); + return; + } + setState(() { + _saving = true; + _error = null; + }); + try { + if (await file.length() > 150 * 1024 * 1024) { + throw StateError('Event file exceeds 150 MB.'); + } + await ref + .read(attachmentUploadCoordinatorProvider) + .uploadEvent( + client: ref.read( + microsoftCalendarApiClientForAccountProvider( + widget.item.accountId, + ), + ), + accountId: widget.item.accountId, + calendarId: widget.item.providerCalendarId, + eventId: eventId, + name: name, + contentType: file.mimeType ?? 'application/octet-stream', + bytes: await file.readAsBytes(), + ); + _refresh(); + } on Object catch (error) { + _refresh(); + if (mounted) setState(() => _error = context.l10n.exportFailed('$error')); + } finally { + if (mounted) setState(() => _saving = false); + } + } + + Future _reconcileUpload(String eventId) async { + setState(() => _saving = true); + try { + await ref + .read(attachmentUploadCoordinatorProvider) + .reconcileEvent( + client: ref.read( + microsoftCalendarApiClientForAccountProvider( + widget.item.accountId, + ), + ), + accountId: widget.item.accountId, + calendarId: widget.item.providerCalendarId, + eventId: eventId, + ); + _refresh(); + } on Object catch (error) { + if (mounted) setState(() => _error = context.l10n.exportFailed('$error')); + } finally { + if (mounted) setState(() => _saving = false); + } + } + + Future _cancelUpload(String eventId) async { + setState(() => _saving = true); + try { + await ref + .read(attachmentUploadCoordinatorProvider) + .cancelEvent( + client: ref.read( + microsoftCalendarApiClientForAccountProvider( + widget.item.accountId, + ), + ), + accountId: widget.item.accountId, + calendarId: widget.item.providerCalendarId, + eventId: eventId, + ); + _refresh(); + } on Object catch (error) { + if (mounted) setState(() => _error = context.l10n.exportFailed('$error')); + } finally { + if (mounted) setState(() => _saving = false); + } + } + + Future _resolveUpload( + AttachmentUploadKey key, { + required bool exists, + }) async { + final confirmed = await showDialog( + context: context, + builder: (dialogContext) => ContentDialog( + title: Text(exists ? context.l10n.completed : context.l10n.retry), + content: Text(context.l10n.attachmentUploadUnresolved), + actions: [ + Button( + onPressed: () => Navigator.pop(dialogContext, false), + child: Text(context.l10n.cancel), + ), + FilledButton( + onPressed: () => Navigator.pop(dialogContext, true), + child: Text(exists ? context.l10n.completed : context.l10n.retry), + ), + ], + ), + ); + if (confirmed != true || !mounted) return; + ref + .read(attachmentUploadCoordinatorProvider) + .resolveUncertainManually(key, exists: exists); + _refresh(); + } + + Future _removeReference(EventAttachmentLink link) async { + final confirmed = await showDialog( + context: context, + builder: (dialogContext) => ContentDialog( + title: Text(context.l10n.delete), + content: Text(link.name), + actions: [ + Button( + onPressed: () => Navigator.pop(dialogContext, false), + child: Text(context.l10n.cancel), + ), + FilledButton( + onPressed: () => Navigator.pop(dialogContext, true), + child: Text(context.l10n.delete), + ), + ], + ), + ); + if (confirmed != true || !mounted) return; + await _changeReference(removeFileUrl: link.url); + } + + Future _changeReference({ + String? addFileUrl, + String? removeFileUrl, + }) async { + setState(() { + _saving = true; + _error = null; + }); + try { + final List links; + var cacheUpdated = true; + if (widget.item.provider == BusyProvider.google) { + final changed = await changeGoogleEventAttachmentReference( + item: widget.item, + client: ref.read( + googleCalendarApiClientForAccountProvider(widget.item.accountId), + ), + repository: ref.read(calendarRepositoryProvider), + addFileUrl: addFileUrl, + removeFileUrl: removeFileUrl, + ); + links = changed.links; + cacheUpdated = changed.cacheUpdated; + } else { + final detail = await ref + .read(calendarRepositoryProvider) + .changeNextcloudUriAttachmentReference( + accountId: widget.item.accountId, + eventId: widget.item.id, + addUrl: addFileUrl, + removeUrl: removeFileUrl, + ); + links = eventAttachmentLinks(detail?.attachments); + cacheUpdated = detail != null; + } + if (!mounted) return; + setState(() { + _referenceLinks = links; + if (!cacheUpdated) { + _error = context.l10n.refreshFailed(context.l10n.attachments); + } + }); + } on Object catch (error) { + if (mounted) setState(() => _error = context.l10n.exportFailed('$error')); + } finally { + if (mounted) setState(() => _saving = false); + } + } + + Future _remove(MicrosoftEventAttachment attachment) async { + final eventId = widget.item.providerEventId; + if (eventId == null || !widget.item.capabilities.canEdit) return; + final confirmed = await showDialog( + context: context, + builder: (context) => ContentDialog( + title: Text(context.l10n.delete), + content: Text(attachment.name), + actions: [ + Button( + onPressed: () => Navigator.pop(context, false), + child: Text(context.l10n.cancel), + ), + FilledButton( + onPressed: () => Navigator.pop(context, true), + child: Text(context.l10n.delete), + ), + ], + ), + ); + if (confirmed != true || !mounted) return; + setState(() { + _saving = true; + _error = null; + }); + try { + await ref + .read( + microsoftCalendarApiClientForAccountProvider(widget.item.accountId), + ) + .deleteEventAttachment( + calendarId: widget.item.providerCalendarId, + eventId: eventId, + attachmentId: attachment.id, + ); + ref + .read(attachmentUploadCoordinatorProvider) + .attachmentRemoved( + AttachmentUploadCoordinator.eventKey( + widget.item.accountId, + widget.item.providerCalendarId, + eventId, + ), + attachment.id, + ); + _refresh(); + } on Object catch (error) { + _refresh(); + if (mounted) setState(() => _error = context.l10n.exportFailed('$error')); + } finally { + if (mounted) setState(() => _saving = false); + } + } + + Future _open(String url) async { + if (await openScheduleWebLink(url) || !mounted) return; + setState(() => _error = context.l10n.eventLinkOpenFailed); + } + + Future _save(MicrosoftEventAttachment attachment) async { + final eventId = widget.item.providerEventId; + if (eventId == null) return; + setState(() { + _saving = true; + _error = null; + }); + try { + final bytes = await ref + .read( + microsoftCalendarApiClientForAccountProvider(widget.item.accountId), + ) + .downloadEventAttachment( + calendarId: widget.item.providerCalendarId, + eventId: eventId, + attachment: attachment, + ); + await saveAttachmentOnDesktop(name: attachment.name, bytes: bytes); + } on Object catch (error) { + if (mounted) setState(() => _error = context.l10n.exportFailed('$error')); + } finally { + if (mounted) setState(() => _saving = false); + } + } +} diff --git a/lib/src/ui/windows/windows_event_editor_dialog.dart b/lib/src/ui/windows/windows_event_editor_dialog.dart index ff216b91..14aee7af 100644 --- a/lib/src/ui/windows/windows_event_editor_dialog.dart +++ b/lib/src/ui/windows/windows_event_editor_dialog.dart @@ -8,13 +8,18 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../l10n/generated/app_localizations.dart'; import '../../app/app_bootstrap.dart'; import '../../calendar_providers/calendar_mutation.dart'; +import '../../calendar_providers/calendar_provider_capabilities.dart'; import '../../features/accounts/data/accounts_repository.dart'; import '../../features/calendar/data/calendar_repository.dart'; import '../../features/calendar/domain/event_move_policy.dart'; import '../../features/calendar/domain/event_timing_policy.dart'; +import '../../features/calendar/domain/google_status_event.dart'; +import '../../features/calendar/presentation/google_status_event_labels.dart'; import '../../schedule/schedule_event_rescheduling.dart'; import '../../core/time/provider_date_time.dart'; import '../../features/calendar/presentation/event_editor_draft.dart'; +import '../../google_calendar/google_calendar_models.dart'; +import '../../microsoft_calendar/microsoft_calendar_models.dart'; import '../../features/recurrence/domain/event_recurrence_codec.dart'; import '../../features/recurrence/domain/recurrence_rule.dart'; import '../../features/maps/domain/location_result.dart'; @@ -27,6 +32,7 @@ import 'windows_guest_update_dialog.dart'; import 'windows_recurrence_dialog.dart'; import 'windows_time_zone_dialog.dart'; import 'windows_nextcloud_scheduling_dialog.dart'; +import 'windows_cloud_availability_dialog.dart'; Future showWindowsEventEditorDialog( BuildContext context, @@ -74,7 +80,9 @@ Future showWindowsEventEditorDialog( } final title = TextEditingController(text: originalDraft?.title); - final description = TextEditingController(text: originalDraft?.description); + final description = TextEditingController( + text: originalDraft?.editableDescription, + ); final location = TextEditingController(text: originalDraft?.location); final guestEmail = TextEditingController(); final categories = TextEditingController( @@ -142,6 +150,27 @@ Future showWindowsEventEditorDialog( var attendees = [...?originalDraft?.attendees]; var attendeesChanged = false; var categoriesChanged = false; + var eventLabelId = originalDraft?.eventLabelId; + var eventLabelChanged = false; + var eventType = originalDraft?.eventType ?? 'default'; + var googleStatusProperties = { + ...?originalDraft?.googleStatusProperties, + }; + var googleStatusChanged = false; + final statusMessage = TextEditingController( + text: googleStatusProperties['declineMessage']?.toString() ?? '', + ); + final statusLocationLabel = TextEditingController( + text: googleStatusProperties['customLocation'] is Map + ? (googleStatusProperties['customLocation'] as Map)['label'] + ?.toString() ?? + '' + : googleStatusProperties['officeLocation'] is Map + ? (googleStatusProperties['officeLocation'] as Map)['label'] + ?.toString() ?? + '' + : '', + ); var onlineMeeting = originalDraft?.conference != null || (originalDraft?.createConference ?? false); @@ -200,6 +229,8 @@ Future showWindowsEventEditorDialog( remindersChanged || attendeesChanged || categoriesChanged || + eventLabelId != originalDraft?.eventLabelId || + googleStatusChanged || onlineMeeting != initialOnlineMeeting || responseRequested != initialResponseRequested || hideAttendees != initialHideAttendees || @@ -278,7 +309,21 @@ Future showWindowsEventEditorDialog( ? null : (source) { if (source != null) { + if (googleStatusEventTypes.contains( + eventType, + ) && + (source.provider != BusyProvider.google || + !source.primaryCalendar)) { + setState( + () => error = l10n.googleStatusPrimaryOnly, + ); + return; + } setState(() { + if (source.id != selectedSource.id) { + eventLabelId = null; + eventLabelChanged = true; + } selectedSource = source; // Destination conversion belongs to the shared save // policy. Keep the editable data intact while browsing. @@ -290,7 +335,10 @@ Future showWindowsEventEditorDialog( const SizedBox(height: 12), ToggleSwitch( checked: allDay, - onChanged: saving + onChanged: + saving || + eventType == 'focusTime' || + eventType == 'outOfOffice' ? null : (value) => setState(() => allDay = value), content: Text(l10n.allDay), @@ -555,44 +603,45 @@ Future showWindowsEventEditorDialog( ), ), const SizedBox(height: 12), - InfoLabel( - label: l10n.repeat, - child: Button( - onPressed: saving - ? null - : () async { - final result = - await showWindowsRecurrenceDialog( - context, - initial: recurrence, - baseDate: start, - allDay: allDay, - timeZone: - selectedTimeZone ?? - ref.read(localTimeZoneProvider), - providerLabel: selectedSource - .provider - .displayName, - limits: - EventRecurrenceCodec.limitsFor( - selectedSource.provider, - ), - ); - if (result != null) { - setState(() { - recurrence = result; - recurrenceChanged = true; - }); - } - }, - child: Align( - alignment: AlignmentDirectional.centerStart, - child: Text( - _recurrenceLabel(l10n, recurrence.frequency), + if (originalDraft?.providerRecurringEventId == null) + InfoLabel( + label: l10n.repeat, + child: Button( + onPressed: saving + ? null + : () async { + final result = + await showWindowsRecurrenceDialog( + context, + initial: recurrence, + baseDate: start, + allDay: allDay, + timeZone: + selectedTimeZone ?? + ref.read(localTimeZoneProvider), + providerLabel: selectedSource + .provider + .displayName, + limits: + EventRecurrenceCodec.limitsFor( + selectedSource.provider, + ), + ); + if (result != null) { + setState(() { + recurrence = result; + recurrenceChanged = true; + }); + } + }, + child: Align( + alignment: AlignmentDirectional.centerStart, + child: Text( + _recurrenceLabel(l10n, recurrence.frequency), + ), ), ), ), - ), const SizedBox(height: 12), ], ), @@ -634,7 +683,13 @@ Future showWindowsEventEditorDialog( child: Text(_availabilityLabel(l10n, value)), ), ], - onChanged: saving + onChanged: + saving || + (selectedSource.provider == + BusyProvider.google && + googleStatusEventTypes.contains( + eventType, + )) ? null : (value) { if (value != null) { @@ -661,7 +716,11 @@ Future showWindowsEventEditorDialog( child: Text(_visibilityLabel(l10n, value)), ), ], - onChanged: saving + onChanged: + saving || + (selectedSource.provider == + BusyProvider.google && + eventType == 'workingLocation') ? null : (value) { if (value != null) { @@ -714,6 +773,441 @@ Future showWindowsEventEditorDialog( ), ), ], + if (selectedSource.provider == BusyProvider.microsoft) + FutureBuilder>( + key: ValueKey( + 'outlook-categories-${selectedSource.accountId}', + ), + future: ref.read( + microsoftMasterCategoriesProvider( + selectedSource.accountId, + ).future, + ), + builder: (context, snapshot) { + final catalog = snapshot.data; + if (catalog == null) { + return snapshot.hasError + ? Button( + onPressed: () async { + try { + await ref + .read( + microsoftCategoryAuthorizationProvider, + ) + ?.authorizeCategoryAccess( + selectedSource.accountId, + ); + ref.invalidate( + microsoftMasterCategoriesProvider( + selectedSource.accountId, + ), + ); + setState(() {}); + } on Object { + setState( + () => error = l10n + .outlookCategoriesUnavailable, + ); + } + }, + child: Text(l10n.loadOutlookCategories), + ) + : Text(l10n.loadOutlookCategories); + } + return Wrap( + spacing: 6, + runSpacing: 6, + children: [ + for (final category in catalog) + ToggleButton( + checked: _categories( + categories.text, + ).contains(category.displayName), + onChanged: saving + ? null + : (checked) { + final names = _categories( + categories.text, + ).toList(); + if (checked) { + if (!names.contains( + category.displayName, + )) { + names.add( + category.displayName, + ); + } + } else { + names.remove( + category.displayName, + ); + } + setState(() { + categories.text = names.join( + ', ', + ); + categoriesChanged = true; + }); + }, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (microsoftCategorySwatchArgb( + category.color, + ) + case final swatch?) ...[ + Container( + width: 12, + height: 12, + color: Color(swatch), + ), + const SizedBox(width: 5), + ], + Text(category.displayName), + ], + ), + ), + ], + ); + }, + ), + if (selectedSource.provider == BusyProvider.google && + (selectedSource.primaryCalendar || + googleStatusEventTypes.contains( + eventType, + ))) ...[ + const SizedBox(height: 12), + InfoLabel( + label: l10n.googleEventType, + child: ComboBox( + key: const Key('windows-google-event-type'), + isExpanded: true, + value: eventType, + items: [ + for (final value in [ + 'default', + ...googleStatusEventTypes, + ]) + ComboBoxItem( + value: value, + child: Text( + googleEventTypeLabel(l10n, value), + ), + ), + ], + onChanged: saving || originalDraft != null + ? null + : (value) { + if (value == null) return; + setState(() { + eventType = value; + googleStatusProperties = { + ...defaultGoogleStatusProperties( + value, + ), + }; + googleStatusChanged = true; + showAs = value == 'workingLocation' + ? 'transparent' + : 'opaque'; + visibility = value == 'workingLocation' + ? 'public' + : 'default'; + if (value == 'focusTime' || + value == 'outOfOffice') { + allDay = false; + } else if (value == 'workingLocation' && + allDay) { + end = DateTime( + start.year, + start.month, + start.day + 1, + ); + } + statusMessage.clear(); + statusLocationLabel.clear(); + }); + }, + ), + ), + if (eventType == 'focusTime' || + eventType == 'outOfOffice') ...[ + const SizedBox(height: 12), + InfoLabel( + label: l10n.googleDeclineInvitations, + child: ComboBox( + isExpanded: true, + value: + googleStatusProperties['autoDeclineMode'] + ?.toString() ?? + 'declineNone', + items: [ + for (final value in const [ + 'declineNone', + 'declineOnlyNewConflictingInvitations', + 'declineAllConflictingInvitations', + ]) + ComboBoxItem( + value: value, + child: Text( + googleAutoDeclineLabel(l10n, value), + ), + ), + ], + onChanged: saving + ? null + : (value) { + if (value == null) return; + setState(() { + googleStatusProperties = { + ...googleStatusProperties, + 'autoDeclineMode': value, + }; + googleStatusChanged = true; + }); + }, + ), + ), + if (eventType == 'focusTime') ...[ + const SizedBox(height: 12), + InfoLabel( + label: l10n.googleChatStatus, + child: ComboBox( + isExpanded: true, + value: + googleStatusProperties['chatStatus'] == + 'doNotDisturb' + ? 'doNotDisturb' + : 'available', + items: [ + ComboBoxItem( + value: 'available', + child: Text(l10n.googleChatAvailable), + ), + ComboBoxItem( + value: 'doNotDisturb', + child: Text(l10n.googleChatDoNotDisturb), + ), + ], + onChanged: saving + ? null + : (value) { + if (value == null) return; + setState(() { + googleStatusProperties = { + ...googleStatusProperties, + 'chatStatus': value, + }; + googleStatusChanged = true; + }); + }, + ), + ), + ], + const SizedBox(height: 12), + InfoLabel( + label: l10n.googleDeclineMessage, + child: TextBox( + controller: statusMessage, + enabled: !saving, + onChanged: (value) => setState(() { + googleStatusProperties = { + ...googleStatusProperties, + 'declineMessage': value, + }; + googleStatusChanged = true; + }), + ), + ), + ], + if (eventType == 'workingLocation') ...[ + const SizedBox(height: 12), + InfoLabel( + label: l10n.googleWorkingLocation, + child: ComboBox( + isExpanded: true, + value: + googleStatusProperties['type'] + ?.toString() ?? + 'homeOffice', + items: [ + for (final value in const [ + 'homeOffice', + 'officeLocation', + 'customLocation', + ]) + ComboBoxItem( + value: value, + child: Text( + googleWorkingLocationLabel(l10n, value), + ), + ), + ], + onChanged: saving + ? null + : (value) { + if (value == null) return; + setState(() { + final next = + {...googleStatusProperties} + ..remove('homeOffice') + ..remove('officeLocation') + ..remove('customLocation'); + next['type'] = value; + next[value] = value == 'homeOffice' + ? {} + : { + 'label': + statusLocationLabel.text, + }; + googleStatusProperties = next; + googleStatusChanged = true; + }); + }, + ), + ), + if (googleStatusProperties['type'] != + 'homeOffice') ...[ + const SizedBox(height: 12), + InfoLabel( + label: l10n.googleWorkLocationLabel, + child: TextBox( + controller: statusLocationLabel, + enabled: !saving, + onChanged: (value) => setState(() { + final locationType = + googleStatusProperties['type'] + ?.toString() ?? + 'customLocation'; + final detail = + googleStatusProperties[locationType] + is Map + ? Map.from( + googleStatusProperties[locationType] + as Map, + ) + : {}; + googleStatusProperties = { + ...googleStatusProperties, + locationType: {...detail, 'label': value}, + }; + googleStatusChanged = true; + }), + ), + ), + ], + ], + ], + if (selectedSource.provider == BusyProvider.google) ...[ + const SizedBox(height: 12), + FutureBuilder>( + key: ValueKey( + 'google-labels-${selectedSource.accountId}-${selectedSource.providerCalendarId}', + ), + future: ref.read( + googleEventLabelsForCalendarProvider(( + accountId: selectedSource.accountId, + calendarId: selectedSource.providerCalendarId, + )).future, + ), + builder: (context, snapshot) { + final labels = snapshot.data; + if (labels == null) { + return InfoLabel( + label: l10n.eventLabel, + child: Row( + children: [ + Expanded( + child: Text( + eventLabelId ?? l10n.noneValue, + ), + ), + if (snapshot.hasError) + Button( + onPressed: () { + ref.invalidate( + googleEventLabelsForCalendarProvider( + ( + accountId: + selectedSource.accountId, + calendarId: selectedSource + .providerCalendarId, + ), + ), + ); + setState(() {}); + }, + child: Text(l10n.retry), + ), + ], + ), + ); + } + final byId = { + for (final label in labels) label.id: label, + }; + final values = [ + '', + ...byId.keys, + if (eventLabelId != null && + !byId.containsKey(eventLabelId)) + eventLabelId!, + ]; + return InfoLabel( + label: l10n.eventLabel, + child: ComboBox( + key: const Key('windows-event-label'), + isExpanded: true, + value: eventLabelId ?? '', + items: [ + for (final id in values) + ComboBoxItem( + value: id, + child: Row( + children: [ + if (byId[id] case final label?) ...[ + Container( + width: 12, + height: 12, + color: Color( + 0xff000000 | + int.parse( + label.backgroundColor + .substring(1), + radix: 16, + ), + ), + ), + const SizedBox(width: 6), + ], + Text( + id.isEmpty + ? l10n.noneValue + : byId[id]?.name ?? + l10n.unknownEventLabel( + id, + ), + ), + ], + ), + ), + ], + onChanged: saving + ? null + : (id) => setState(() { + eventLabelId = + id == null || id.isEmpty + ? null + : id; + eventLabelChanged = + eventLabelId != + originalDraft?.eventLabelId; + }), + ), + ); + }, + ), + ], ], ), ), @@ -761,14 +1255,7 @@ Future showWindowsEventEditorDialog( attendees = [ for (final item in attendees) if (identical(item, attendee)) - EventAttendeeDraft( - email: item.email, - displayName: item.displayName, - optional: optional, - self: item.self, - organizer: item.organizer, - responseStatus: item.responseStatus, - ) + item.withOptional(optional) else item, ]; @@ -895,6 +1382,54 @@ Future showWindowsEventEditorDialog( ), child: Text(l10n.nextcloudGuestAvailability), ), + if ((selectedSource.provider == BusyProvider.google || + (selectedSource.provider == BusyProvider.microsoft && + microsoftAvailabilityAccountType( + accounts + .where( + (account) => + account.id == + selectedSource.accountId, + ) + .firstOrNull + ?.tenantId, + ) == + MicrosoftAvailabilityAccountType + .workSchool)) && + attendees.any( + (attendee) => !attendee.self && !attendee.organizer, + )) + Button( + onPressed: saving + ? null + : () => showWindowsCloudAvailabilityDialog( + context, + calendarTimeZone: selectedSource.timeZone, + draft: + (originalDraft ?? + EventEditorDraft.newEvent( + accountId: selectedSource.accountId, + sourceId: selectedSource.id, + providerCalendarId: selectedSource + .providerCalendarId, + start: start, + end: end, + )) + .copyWith( + accountId: selectedSource.accountId, + sourceId: selectedSource.id, + providerCalendarId: + selectedSource.providerCalendarId, + start: start, + end: end, + allDay: allDay, + startTimeZone: selectedTimeZone, + endTimeZone: endTimeZone, + attendees: attendees, + ), + ), + child: Text(l10n.nextcloudGuestAvailability), + ), if (error != null) ...[ const SizedBox(height: 12), InfoBar( @@ -968,7 +1503,13 @@ Future showWindowsEventEditorDialog( ? location.text : originalDraft.location, locationChange: locationChange, - description: description.text.trim(), + description: + description.text == + (originalDraft + ?.editableDescription ?? + '') + ? originalDraft?.description + : description.text.trim(), startTimeZone: selectedTimeZone, endTimeZone: endTimeZone, recurrence: recurrenceChanged @@ -1009,6 +1550,28 @@ Future showWindowsEventEditorDialog( ? _categories(categories.text) : null, categoriesChanged: categoriesChanged, + eventLabelId: + eventLabelChanged && + eventLabelId != null + ? eventLabelId + : null, + clearEventLabelId: + eventLabelChanged && + eventLabelId == null, + eventLabelChanged: eventLabelChanged, + eventType: + selectedSource.provider == + BusyProvider.google + ? eventType + : null, + googleStatusProperties: + selectedSource.provider == + BusyProvider.google && + (googleStatusChanged || + originalDraft == null) + ? googleStatusProperties + : null, + googleStatusChanged: googleStatusChanged, createConference: onlineMeeting && originalDraft?.conference == null, @@ -1187,6 +1750,8 @@ Future showWindowsEventEditorDialog( location.dispose(); guestEmail.dispose(); categories.dispose(); + statusMessage.dispose(); + statusLocationLabel.dispose(); return saved; } diff --git a/lib/src/ui/windows/windows_schedule_page.dart b/lib/src/ui/windows/windows_schedule_page.dart index c40bb11c..6df5b14a 100644 --- a/lib/src/ui/windows/windows_schedule_page.dart +++ b/lib/src/ui/windows/windows_schedule_page.dart @@ -19,7 +19,9 @@ import '../../calendar_providers/calendar_mutation.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/calendar/presentation/google_status_event_labels.dart'; import '../../features/schedule/presentation/schedule_item_exporter.dart'; +import '../../features/schedule/presentation/cloud_calendar_series_export.dart'; import '../../features/task_lists/data/task_lists_repository.dart'; import '../../schedule/schedule_filters.dart'; import '../../schedule/schedule_commands.dart'; @@ -34,6 +36,7 @@ import '../../features/tasks/domain/task_mutation_result.dart'; import '../common/schedule/schedule_interactions.dart'; import '../common/schedule/schedule_preview_label.dart'; import 'windows_schedule_day_week_view.dart'; +import 'windows_event_attachments_dialog.dart'; import '../../schedule/schedule_range.dart'; import '../../schedule/schedule_navigation_intent.dart'; import '../../schedule/schedule_source_visibility.dart'; @@ -48,6 +51,7 @@ 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 '../../features/schedule/presentation/schedule_event_details_format.dart'; import 'windows_task_details_dialog.dart'; import 'windows_task_editor_dialog.dart'; @@ -371,6 +375,11 @@ class _WindowsSchedulePageState extends ConsumerState { range: range, locale: locale, sources: sources, + coverageFilters: ScheduleFilters( + accountIds: {for (final account in accounts) account.id}, + sourceIds: visibility.visibleCalendarSourceIds, + sourceFilterActive: true, + ), navigationIntent: presentationNavigationIntent, mutationIntent: presentationMutationIntent, ); @@ -671,19 +680,47 @@ class _WindowsSchedulePageState extends ConsumerState { if (snapshot.hasError) { return _scheduleLoadError(context); } - return _AgendaList( - key: const ValueKey('windows-search-results'), - items: snapshot.data ?? const [], - locale: locale, - onOpen: _showItemDetails, - searchCriteria: criteria, - searchQuery: query, - taskMutationIntent: mutationIntent, - onTaskMutationConsumed: _consumeTaskMutation, - emptyBuilder: (context) => _WindowsScheduleEmptyState( - searching: true, - noVisibleSources: !criteria.hasSources, - ), + return Column( + children: [ + if (criteria.range case final searchRange?) + if (ref + .read(scheduleRepositoryProvider) + .cloudCoverageCompleteFor( + searchRange, + filters: criteria.filters(query), + ) != + true) + InfoBar( + title: Text( + ref + .read(scheduleRepositoryProvider) + .cloudCoverageCompleteFor( + searchRange, + filters: criteria.filters(query), + ) == + null + ? AppLocalizations.of(context).scheduleLoading + : AppLocalizations.of(context).scheduleRangeIncomplete, + ), + severity: InfoBarSeverity.info, + ), + Expanded( + child: _AgendaList( + key: const ValueKey('windows-search-results'), + items: snapshot.data ?? const [], + locale: locale, + onOpen: _showItemDetails, + searchCriteria: criteria, + searchQuery: query, + taskMutationIntent: mutationIntent, + onTaskMutationConsumed: _consumeTaskMutation, + emptyBuilder: (context) => _WindowsScheduleEmptyState( + searching: true, + noVisibleSources: !criteria.hasSources, + ), + ), + ), + ], ); }, ); @@ -696,6 +733,7 @@ class _WindowsSchedulePageState extends ConsumerState { required ScheduleRange range, required String locale, required List sources, + required ScheduleFilters coverageFilters, required ScheduleNavigationIntent? navigationIntent, required TaskListMutationIntent? mutationIntent, }) => FutureBuilder>( @@ -709,35 +747,59 @@ class _WindowsSchedulePageState extends ConsumerState { if (snapshot.hasError) { return _scheduleLoadError(context); } - return BusyMaxKeyedCrossfade( - transitionKey: mode, - child: _ScheduleModeView( - firstWeekday: firstWeekday, - mode: mode, - selectedDate: selectedDate, - range: range, - items: snapshot.data ?? const [], - locale: locale, - onOpen: _showItemDetails, - onSelectDate: _openDay, - onLoadMoreAgenda: _loadMoreAgenda, - onVisibleDateChanged: _selectDate, - navigationIntent: navigationIntent, - taskMutationIntent: mutationIntent, - onTaskMutationConsumed: _consumeTaskMutation, - 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, - ), + return Column( + children: [ + if (ref + .read(scheduleRepositoryProvider) + .cloudCoverageCompleteFor(range, filters: coverageFilters) != + true) + InfoBar( + title: Text( + ref + .read(scheduleRepositoryProvider) + .cloudCoverageCompleteFor( + range, + filters: coverageFilters, + ) == + null + ? AppLocalizations.of(context).scheduleLoading + : AppLocalizations.of(context).scheduleRangeIncomplete, + ), + severity: InfoBarSeverity.info, + ), + Expanded( + child: BusyMaxKeyedCrossfade( + transitionKey: mode, + child: _ScheduleModeView( + firstWeekday: firstWeekday, + mode: mode, + selectedDate: selectedDate, + range: range, + items: snapshot.data ?? const [], + locale: locale, + onOpen: _showItemDetails, + onSelectDate: _openDay, + onLoadMoreAgenda: _loadMoreAgenda, + onVisibleDateChanged: _selectDate, + navigationIntent: navigationIntent, + taskMutationIntent: mutationIntent, + onTaskMutationConsumed: _consumeTaskMutation, + 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, + ), + ), + ), + ], ); }, ); @@ -1387,7 +1449,9 @@ class _WindowsSchedulePageState extends ConsumerState { if (!mounted) return; final l10n = AppLocalizations.of(context); final locale = Localizations.localeOf(context).toLanguageTag(); - final time = _itemDateLabel(context, item, locale); + final time = item is CalendarScheduleItem + ? scheduleEventIntervalLabel(context, item) + : _itemDateLabel(context, item, locale); return showDialog( context: context, builder: (dialogContext) => ContentDialog( @@ -1402,8 +1466,14 @@ class _WindowsSchedulePageState extends ConsumerState { time, ?item.sourceName, ?item.accountDisplayName, - if (item case CalendarScheduleItem(:final description?)) - description, + if (item is CalendarScheduleItem) + calendarEventDescription(item), + if (item is CalendarScheduleItem) + ...googleStatusDetailLines( + l10n, + item.eventType, + item.googleStatusProperties, + ), if (item case TaskScheduleItem(:final notes?)) notes, ].where((value) => value.trim().isNotEmpty).join('\n'), ), @@ -1417,14 +1487,57 @@ class _WindowsSchedulePageState extends ConsumerState { ], ], if (item is CalendarScheduleItem) ...[ + if (item.joinMeetingUrl case final meetingLink?) + Button( + onPressed: () => unawaited( + _openEventWebLink(meetingLink, meeting: true), + ), + child: Text(l10n.joinMeeting), + ), + if (item.eventLinkUrl case final eventLink?) + Button( + onPressed: () => unawaited(_openEventWebLink(eventLink)), + child: Text(l10n.eventLink), + ), + if (item.attachmentsMayExist || item.attachmentLinks.isNotEmpty) + Text(l10n.attachments), + for (final attachment in item.attachmentLinks) + Button( + onPressed: () => + unawaited(_openEventWebLink(attachment.url)), + child: Text(attachment.name), + ), + if (item.attachmentsMayExist && !item.attachmentsLoaded) + Text(l10n.attachmentsNotLoaded), + if (item.provider == BusyProvider.microsoft && + item.providerEventId != null) + Button( + onPressed: () { + Navigator.pop(dialogContext); + unawaited( + showWindowsEventAttachmentsDialog(context, item), + ); + }, + child: Text(l10n.attachments), + ), + for (final uri in calendarEventDescriptionLinks(item)) + Button( + onPressed: () => + unawaited(_openEventWebLink(uri.toString())), + child: Text(uri.toString()), + ), if (item.canSendReply) Text(l10n.nextcloudAttendeeRestrictions), if (item.organizer != null) SelectableText( - '${l10n.organizer}: ${item.organizer!['displayName'] ?? item.organizer!['email'] ?? item.organizer!['value'] ?? ''}', + '${l10n.organizer}: ${scheduleEventPersonName(item.organizer)}', + ), + if (item.currentUserResponse case final response?) + SelectableText( + '${l10n.yourResponse}: ${scheduleEventResponseLabel(dialogContext, response)}', ), for (final attendee in item.attendees) ...[ SelectableText( - '${attendee['displayName'] ?? attendee['email'] ?? attendee['value'] ?? ''} · ${attendee['responseStatus'] ?? ''}', + scheduleEventAttendeeLabel(dialogContext, attendee), ), if (attendee['scheduleStatus'] != null) SelectableText( @@ -1463,6 +1576,16 @@ class _WindowsSchedulePageState extends ConsumerState { ), ], ], + if (item is TaskScheduleItem) + for (final link in item.availableSourceLinks) + Button( + onPressed: () => unawaited(_openEventWebLink(link.url)), + child: Text( + link.label?.isNotEmpty == true + ? link.label! + : l10n.openInProvider, + ), + ), ], ), ), @@ -1510,6 +1633,21 @@ class _WindowsSchedulePageState extends ConsumerState { ); } + Future _openEventWebLink(String value, {bool meeting = false}) async { + if (await openScheduleWebLink(value) || !mounted) return; + await displayInfoBar( + context, + builder: (context, close) => InfoBar( + title: Text( + meeting + ? AppLocalizations.of(context).joinMeetingFailed + : AppLocalizations.of(context).eventLinkOpenFailed, + ), + severity: InfoBarSeverity.error, + ), + ); + } + Widget _buildLocationRow( BuildContext dialogContext, ScheduleItem item, @@ -1655,15 +1793,67 @@ class _WindowsSchedulePageState extends ConsumerState { Future _export(ScheduleItem item) async { final l10n = AppLocalizations.of(context); try { - final rawICalendar = item is CalendarScheduleItem - ? await ref - .read(calendarRepositoryProvider) - .nativeEventExport(item.id) - : item is TaskScheduleItem && item.provider == BusyProvider.nextcloud - ? await ref - .read(tasksRepositoryForAccountProvider(item.accountId)) - .nativeTaskExport(item.sourceId, item.id) - : null; + String? rawICalendar; + if (item is CalendarScheduleItem) { + final repository = ref.read(calendarRepositoryProvider); + final detail = await repository.loadEventDetail(item.id); + if (!mounted) return; + final recurring = + detail != null && + (detail.recurrence != null || + detail.providerRecurringEventId != null); + final series = + recurring && + canExportAuthoritativeEventSeries( + provider: item.provider, + providerEventId: item.providerEventId, + davCollectionId: detail.davCollectionId, + ) + ? await showDialog( + context: context, + builder: (dialogContext) => ContentDialog( + title: Text(l10n.export), + actions: [ + Button( + onPressed: () => Navigator.pop(dialogContext, false), + child: Text(l10n.singleOccurrence), + ), + FilledButton( + onPressed: () => Navigator.pop(dialogContext, true), + child: Text(l10n.entireSeries), + ), + ], + ), + ) + : false; + if (series == null) return; + if (series && item.provider == BusyProvider.google) { + rawICalendar = await exportGoogleEventSeries( + client: ref.read( + googleCalendarApiClientForAccountProvider(item.accountId), + ), + calendarId: item.providerCalendarId, + eventId: item.providerEventId!, + nowUtc: DateTime.now().toUtc(), + ); + } else if (series && item.provider == BusyProvider.microsoft) { + rawICalendar = await exportMicrosoftEventSeries( + client: ref.read( + microsoftCalendarApiClientForAccountProvider(item.accountId), + ), + calendarId: item.providerCalendarId, + eventId: item.providerEventId!, + nowUtc: DateTime.now().toUtc(), + ); + } else if (series || !recurring) { + rawICalendar = await repository.nativeEventExport(item.id); + } + } else if (item is TaskScheduleItem && + item.provider == BusyProvider.nextcloud) { + rawICalendar = await ref + .read(tasksRepositoryForAccountProvider(item.accountId)) + .nativeTaskExport(item.sourceId, item.id); + } final file = await exportScheduleItemWithSaveDialog( item, rawICalendar: rawICalendar, @@ -1776,6 +1966,8 @@ class _WindowsSchedulePageState extends ConsumerState { content: Text( item is CalendarScheduleItem && item.isNextcloudAttendee ? l10n.nextcloudDeclineRemovalWarning + : item is TaskScheduleItem && item.isAssigned + ? '${l10n.deleteTaskConfirmation(item.title)}\n\n${l10n.deleteAssignedTaskWarning}' : item.title, ), actions: [ @@ -1812,7 +2004,11 @@ class _WindowsSchedulePageState extends ConsumerState { case TaskScheduleItem(): await ref .read(tasksRepositoryForAccountProvider(item.accountId)) - .deleteTask(item.sourceId, item.id); + .deleteTask( + item.sourceId, + item.id, + confirmedAssignedSourceDeletion: item.isAssigned, + ); } if (mounted) _reload(); } on Object catch (_) { diff --git a/lib/src/ui/windows/windows_settings_page.dart b/lib/src/ui/windows/windows_settings_page.dart index 7205d606..386de152 100644 --- a/lib/src/ui/windows/windows_settings_page.dart +++ b/lib/src/ui/windows/windows_settings_page.dart @@ -13,6 +13,8 @@ import '../../../l10n/generated/app_localizations.dart'; import '../../app/app_bootstrap.dart'; import '../../features/accounts/data/accounts_repository.dart'; import '../../features/calendar/data/calendar_repository.dart'; +import '../../features/calendar/data/cloud_calendar_sharing_service.dart'; +import '../../features/calendar/data/microsoft_shared_calendar_service.dart'; import '../../features/notifications/desktop_notification_backend.dart'; import '../../features/sync/sync_auth_error.dart'; import '../../features/settings/presentation/launch_at_login_refresh.dart'; @@ -25,6 +27,7 @@ import '../common/busymax_glyph.dart'; import 'windows_account_removal_dialog.dart'; import 'windows_busymax_glyphs.dart'; import 'windows_calendar_activation_flows.dart'; +import 'windows_cloud_calendar_sharing_dialog.dart'; import 'windows_diagnostics_dialog.dart'; import 'windows_feedback_dialog.dart'; import 'windows_keyboard_shortcuts_dialog.dart'; @@ -453,6 +456,46 @@ class _WindowsSettingsPageState extends ConsumerState { accountId: values[index].id, ), ), + if (values[index].provider == BusyProvider.microsoft && + values[index].isSignedIn) + MenuFlyoutItem( + text: Text(l10n.openSharedCalendar), + onPressed: () => unawaited( + _openWindowsSharedCalendar( + context, + ref, + values[index].id, + ), + ), + ), + if (values[index].isSignedIn && + (calendarSources.valueOrNull ?? + const []) + .any( + (source) => + source.accountId == values[index].id && + CloudCalendarSharingService.canManageSource( + source, + ), + )) + MenuFlyoutItem( + text: Text(l10n.manageCalendarSharing), + onPressed: () => unawaited( + showWindowsCloudCalendarSharingDialog( + context, + sources: [ + for (final source + in calendarSources.valueOrNull ?? + const []) + if (source.accountId == values[index].id && + CloudCalendarSharingService.canManageSource( + source, + )) + source, + ], + ), + ), + ), MenuFlyoutItem( leading: Icon( windowsBusyMaxGlyph(BusyMaxGlyph.delete), @@ -794,7 +837,7 @@ class _WindowsCalendarSettingsColumnLabel extends StatelessWidget { } } -class _WindowsCalendarSettingsRow extends StatelessWidget { +class _WindowsCalendarSettingsRow extends ConsumerWidget { const _WindowsCalendarSettingsRow({ super.key, required this.source, @@ -810,7 +853,7 @@ class _WindowsCalendarSettingsRow extends StatelessWidget { onProviderVisibilityChanged; @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { final l10n = AppLocalizations.of(context); final canChangeProviderVisibility = source.capabilities.canChangeProviderVisibility; @@ -848,6 +891,50 @@ class _WindowsCalendarSettingsRow extends StatelessWidget { semanticLabel: l10n.visibility, ), ), + if (source.provider == BusyProvider.microsoft && + source.capabilities.removalMode == + CalendarRemovalMode.removeFromList) + IconButton( + key: ValueKey('settings-calendar-remove-${source.id}'), + icon: Icon(windowsBusyMaxGlyph(BusyMaxGlyph.delete)), + onPressed: () async { + final confirmed = await showDialog( + context: context, + barrierDismissible: false, + builder: (dialogContext) => ContentDialog( + title: Text(l10n.removeFromMyCalendars), + content: Text( + l10n.removeOpenedSharedCalendarConfirmation( + source.summary, + ), + ), + actions: [ + Button( + onPressed: () => Navigator.pop(dialogContext, false), + child: Text(l10n.cancel), + ), + FilledButton( + onPressed: () => Navigator.pop(dialogContext, true), + child: Text(l10n.removeAction), + ), + ], + ), + ); + if (confirmed != true || !context.mounted) return; + try { + await ref + .read(calendarRepositoryProvider) + .deleteLocalSource(source.id); + } on Object catch (error) { + if (context.mounted) { + await _showWindowsMessage( + context, + l10n.calendarUpdateFailed('$error'), + ); + } + } + }, + ), ], ), ); @@ -1106,6 +1193,58 @@ Future _runWindowsSubscriptionOperation( } } +Future _openWindowsSharedCalendar( + BuildContext context, + WidgetRef ref, + String accountId, +) async { + final l10n = AppLocalizations.of(context); + final owner = await _showWindowsTextPrompt( + context, + title: l10n.openSharedCalendar, + label: l10n.calendarOwnerEmail, + initialValue: '', + actionLabel: l10n.openSharedCalendar, + ); + if (!context.mounted || owner == null || owner.trim().isEmpty) return; + try { + final result = await ref + .read(microsoftSharedCalendarServiceProvider) + .openPrimaryCalendar(accountId: accountId, owner: owner); + if (context.mounted && + result.outcome == MicrosoftSharedCalendarOpenOutcome.rangeUnavailable) { + await showDialog( + context: context, + builder: (dialogContext) => ContentDialog( + title: Text(l10n.openSharedCalendar), + content: Text(l10n.scheduleRangeIncomplete), + actions: [ + Button( + onPressed: () => Navigator.pop(dialogContext), + child: Text(l10n.close), + ), + ], + ), + ); + } + } on Object catch (error) { + if (!context.mounted) return; + await showDialog( + context: context, + builder: (dialogContext) => ContentDialog( + title: Text(l10n.operationFailed), + content: SelectableText('$error'), + actions: [ + Button( + onPressed: () => Navigator.pop(dialogContext), + child: Text(l10n.close), + ), + ], + ), + ); + } +} + Future _showWindowsTextPrompt( BuildContext context, { required String title, diff --git a/lib/src/ui/windows/windows_task_details_dialog.dart b/lib/src/ui/windows/windows_task_details_dialog.dart index 4c628871..2aa067a0 100644 --- a/lib/src/ui/windows/windows_task_details_dialog.dart +++ b/lib/src/ui/windows/windows_task_details_dialog.dart @@ -5,6 +5,7 @@ import 'dart:async'; import 'dart:convert'; import 'package:fluent_ui/fluent_ui.dart'; +import 'package:file_selector/file_selector.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:intl/intl.dart'; @@ -14,8 +15,15 @@ import '../../dav/ical/ical_task_alarm.dart'; import '../../features/tasks/domain/task_capabilities.dart'; import '../../features/tasks/domain/task_mutation_result.dart'; import '../../features/tasks/data/tasks_repository.dart'; +import '../../features/tasks/domain/task_source_links.dart'; import '../../features/tasks/presentation/task_details_draft.dart'; import '../../features/schedule/presentation/schedule_item_exporter.dart'; +import '../../features/schedule/presentation/schedule_event_details_format.dart'; +import '../../features/schedule/presentation/attachment_download.dart'; +import '../../features/schedule/presentation/attachment_upload_coordinator.dart'; +import '../../microsoft_todo/api/microsoft_todo_api_client.dart'; +import '../../microsoft_todo/api/microsoft_todo_api_models.dart'; +import '../../microsoft_calendar/microsoft_calendar_models.dart'; import '../../features/maps/domain/location_result.dart'; import '../../features/maps/application/external_location_launcher.dart'; import '../../features/maps/application/location_destination_resolver.dart'; @@ -141,6 +149,8 @@ Future showWindowsTaskDetailsDialog( TaskScheduleItem? nextTask; String? error; String? message; + Future>? linkedResourcesFuture; + Future>? attachmentsFuture; TaskDetailsDraft currentDraft() => originalDraft.copyWith( taskListId: selectedTaskListId, title: title.text.trim(), @@ -207,6 +217,12 @@ Future showWindowsTaskDetailsDialog( ], builder: (context, setState) { final l10n = AppLocalizations.of(context); + final attachmentUploadKey = AttachmentUploadCoordinator.taskKey( + original.accountId, + original.taskListId, + original.id, + ); + final uploads = ref.watch(attachmentUploadCoordinatorProvider); final enabled = !busy && capabilities.canUpdateTasks; final provisionalDraft = currentDraft(); final validUrl = @@ -351,10 +367,528 @@ Future showWindowsTaskDetailsDialog( label: l10n.notes, child: TextBox( controller: notes, - enabled: !busy && capabilities.canUpdateTasks, + enabled: + !busy && + capabilities.canUpdateTasks && + !original.googleAssignment.isFromDocument, maxLines: 5, ), ), + if (account.provider == BusyProvider.google) + for (final link in original.googleSourceLinks) ...[ + const SizedBox(height: 12), + Button( + onPressed: () async { + if (!await openScheduleWebLink(link.url) && + context.mounted) { + setState(() => error = l10n.eventLinkOpenFailed); + } + }, + child: Text( + link.label?.isNotEmpty == true + ? link.label! + : l10n.openInProvider, + ), + ), + ], + if (account.provider == BusyProvider.microsoft) ...[ + Button( + onPressed: () { + final key = ( + accountId: original.accountId, + taskListId: original.taskListId, + taskId: original.id, + ); + ref.invalidate( + microsoftTaskLinkedResourcesProvider(key), + ); + setState(() { + linkedResourcesFuture = ref.read( + microsoftTaskLinkedResourcesProvider(key).future, + ); + }); + }, + child: Text(l10n.linkedResources), + ), + if (linkedResourcesFuture case final future?) + FutureBuilder>( + future: future, + builder: (context, snapshot) { + if (snapshot.hasError) { + return Text(l10n.operationFailed); + } + if (!snapshot.hasData) return const ProgressRing(); + if (snapshot.data!.isEmpty) { + return Text(l10n.noLinkedResources); + } + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (final link in snapshot.data!) ...[ + const SizedBox(height: 12), + Button( + onPressed: () async { + if (!await openScheduleWebLink(link.url) && + context.mounted) { + setState( + () => error = l10n.eventLinkOpenFailed, + ); + } + }, + child: Text( + link.label?.isNotEmpty == true + ? link.label! + : l10n.openInProvider, + ), + ), + ], + ], + ); + }, + ), + Button( + key: const Key('task-attachments-load'), + onPressed: () { + final key = ( + accountId: original.accountId, + taskListId: original.taskListId, + taskId: original.id, + ); + ref.invalidate(microsoftTaskAttachmentsProvider(key)); + setState(() { + attachmentsFuture = ref.read( + microsoftTaskAttachmentsProvider(key).future, + ); + }); + }, + child: Text(l10n.attachments), + ), + if (capabilities.canUpdateTasks && + !original.pendingDelete && + !original.id.startsWith('local-task-')) + Button( + onPressed: + busy || + !ref + .watch(attachmentUploadCoordinatorProvider) + .canSubmit( + AttachmentUploadCoordinator.taskKey( + original.accountId, + original.taskListId, + original.id, + ), + ) + ? null + : () async { + final file = await openFile(); + if (file == null || !context.mounted) return; + setState(() => busy = true); + try { + final name = safeAttachmentFileName( + file.name, + ); + if (name == null) { + throw const FormatException( + 'Invalid attachment name.', + ); + } + if (await file.length() > 25 * 1024 * 1024) { + throw StateError( + 'Task file exceeds 25 MB.', + ); + } + final client = ref.read( + microsoftTodoApiClientForAccountProvider( + original.accountId, + ), + ); + if (client + is! MicrosoftTodoAttachmentsApiClient) { + throw StateError( + 'Microsoft task attachments are unavailable.', + ); + } + await ref + .read(attachmentUploadCoordinatorProvider) + .uploadTask( + client: + client + as MicrosoftTodoAttachmentsApiClient, + accountId: original.accountId, + taskListId: original.taskListId, + taskId: original.id, + name: name, + contentType: + file.mimeType ?? + 'application/octet-stream', + bytes: await file.readAsBytes(), + ); + final key = ( + accountId: original.accountId, + taskListId: original.taskListId, + taskId: original.id, + ); + ref.invalidate( + microsoftTaskAttachmentsProvider(key), + ); + attachmentsFuture = ref.read( + microsoftTaskAttachmentsProvider( + key, + ).future, + ); + } on Object catch (failure) { + error = l10n.exportFailed('$failure'); + final key = ( + accountId: original.accountId, + taskListId: original.taskListId, + taskId: original.id, + ); + ref.invalidate( + microsoftTaskAttachmentsProvider(key), + ); + } finally { + if (context.mounted) { + setState(() => busy = false); + } + } + }, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(FluentIcons.add), + Text(l10n.attachments), + ], + ), + ), + if (ref + .watch(attachmentUploadCoordinatorProvider) + .retryAfter( + AttachmentUploadCoordinator.taskKey( + original.accountId, + original.taskListId, + original.id, + ), + ) != + null) + Text(l10n.attachmentUploadRateLimited), + if (ref + .watch(attachmentUploadCoordinatorProvider) + .needsReconciliation( + AttachmentUploadCoordinator.taskKey( + original.accountId, + original.taskListId, + original.id, + ), + )) ...[ + Text( + uploads.confirmedId(attachmentUploadKey) == null + ? l10n.attachmentUploadUnresolved + : l10n.completed, + ), + Button( + onPressed: busy + ? null + : () async { + final client = ref.read( + microsoftTodoApiClientForAccountProvider( + original.accountId, + ), + ); + if (client + is! MicrosoftTodoAttachmentsApiClient) { + return; + } + setState(() => busy = true); + try { + await ref + .read(attachmentUploadCoordinatorProvider) + .reconcileTask( + client: + client + as MicrosoftTodoAttachmentsApiClient, + accountId: original.accountId, + taskListId: original.taskListId, + taskId: original.id, + ); + final key = ( + accountId: original.accountId, + taskListId: original.taskListId, + taskId: original.id, + ); + ref.invalidate( + microsoftTaskAttachmentsProvider(key), + ); + attachmentsFuture = ref.read( + microsoftTaskAttachmentsProvider( + key, + ).future, + ); + } on Object catch (failure) { + error = l10n.exportFailed('$failure'); + } finally { + if (context.mounted) { + setState(() => busy = false); + } + } + }, + child: Text(l10n.refresh), + ), + if (uploads.hasResumableSession(attachmentUploadKey)) + Button( + onPressed: busy + ? null + : () async { + final client = ref.read( + microsoftTodoApiClientForAccountProvider( + original.accountId, + ), + ); + if (client + is! MicrosoftTodoAttachmentsApiClient) { + return; + } + setState(() => busy = true); + try { + await uploads.cancelTask( + client: + client + as MicrosoftTodoAttachmentsApiClient, + accountId: original.accountId, + taskListId: original.taskListId, + taskId: original.id, + ); + final key = ( + accountId: original.accountId, + taskListId: original.taskListId, + taskId: original.id, + ); + ref.invalidate( + microsoftTaskAttachmentsProvider(key), + ); + attachmentsFuture = ref.read( + microsoftTaskAttachmentsProvider( + key, + ).future, + ); + } on Object catch (failure) { + error = l10n.exportFailed('$failure'); + } finally { + if (context.mounted) { + setState(() => busy = false); + } + } + }, + child: Text(l10n.cancel), + ), + if (uploads.canResolveManually(attachmentUploadKey)) + for (final exists in [true, false]) + Button( + onPressed: busy + ? null + : () async { + final label = exists + ? l10n.completed + : l10n.retry; + final confirmed = await showDialog( + context: context, + builder: (dialogContext) => ContentDialog( + title: Text(label), + content: Text( + l10n.attachmentUploadUnresolved, + ), + actions: [ + Button( + onPressed: () => Navigator.pop( + dialogContext, + false, + ), + child: Text(l10n.cancel), + ), + FilledButton( + onPressed: () => Navigator.pop( + dialogContext, + true, + ), + child: Text(label), + ), + ], + ), + ); + if (confirmed != true || !context.mounted) { + return; + } + uploads.resolveUncertainManually( + attachmentUploadKey, + exists: exists, + ); + final key = ( + accountId: original.accountId, + taskListId: original.taskListId, + taskId: original.id, + ); + ref.invalidate( + microsoftTaskAttachmentsProvider(key), + ); + final future = ref.read( + microsoftTaskAttachmentsProvider( + key, + ).future, + ); + setState(() { + attachmentsFuture = future; + }); + }, + child: Text(exists ? l10n.completed : l10n.retry), + ), + ], + if (attachmentsFuture case final future?) + FutureBuilder>( + future: future, + builder: (context, snapshot) { + if (snapshot.hasError) { + return Text(l10n.attachmentsNotLoaded); + } + if (!snapshot.hasData) return const ProgressRing(); + if (snapshot.data!.isEmpty) { + return Text(l10n.noneValue); + } + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (final attachment in snapshot.data!) + Row( + children: [ + Button( + onPressed: attachment.isFile + ? () async { + try { + final name = + safeAttachmentFileName( + attachment.name, + ); + if (name == null) { + throw const FormatException( + 'Invalid attachment name.', + ); + } + final client = ref.read( + microsoftTodoApiClientForAccountProvider( + original.accountId, + ), + ); + if (client + is! MicrosoftTodoAttachmentsApiClient) { + throw StateError( + 'Microsoft task attachments are unavailable.', + ); + } + final bytes = + await (client + as MicrosoftTodoAttachmentsApiClient) + .downloadTaskAttachment( + taskListId: original + .taskListId, + taskId: original.id, + attachmentId: + attachment.id, + ); + await saveAttachmentOnDesktop( + name: name, + bytes: bytes, + ); + } on Object catch (failure) { + if (context.mounted) { + setState( + () => error = l10n + .exportFailed( + '$failure', + ), + ); + } + } + } + : null, + child: Text(attachment.name), + ), + if (capabilities.canUpdateTasks && + !original.pendingDelete && + !original.id.startsWith('local-task-')) + Button( + onPressed: busy + ? null + : () async { + setState(() => busy = true); + try { + final client = ref.read( + microsoftTodoApiClientForAccountProvider( + original.accountId, + ), + ); + if (client + is! MicrosoftTodoAttachmentsApiClient) { + throw StateError( + 'Microsoft task attachments are unavailable.', + ); + } + await (client + as MicrosoftTodoAttachmentsApiClient) + .deleteTaskAttachment( + taskListId: + original.taskListId, + taskId: original.id, + attachmentId: + attachment.id, + ); + ref + .read( + attachmentUploadCoordinatorProvider, + ) + .attachmentRemoved( + AttachmentUploadCoordinator.taskKey( + original.accountId, + original.taskListId, + original.id, + ), + attachment.id, + ); + final key = ( + accountId: + original.accountId, + taskListId: + original.taskListId, + taskId: original.id, + ); + ref.invalidate( + microsoftTaskAttachmentsProvider( + key, + ), + ); + attachmentsFuture = ref.read( + microsoftTaskAttachmentsProvider( + key, + ).future, + ); + } on Object catch (failure) { + error = l10n.exportFailed( + '$failure', + ); + } finally { + if (context.mounted) { + setState( + () => busy = false, + ); + } + } + }, + child: Text(l10n.delete), + ), + ], + ), + ], + ); + }, + ), + ], if (capabilities.supportsReminderDateTime || capabilities.supportsRecurrence) ...[ const SizedBox(height: 12), @@ -486,6 +1020,103 @@ Future showWindowsTaskDetailsDialog( enabled: enabled, ), ), + if (account.provider == BusyProvider.microsoft) + FutureBuilder>( + future: ref.read( + microsoftMasterCategoriesProvider( + task.accountId, + ).future, + ), + builder: (context, snapshot) { + final catalog = snapshot.data; + if (catalog == null) { + return snapshot.hasError + ? Button( + onPressed: () async { + try { + await ref + .read( + microsoftCategoryAuthorizationProvider, + ) + ?.authorizeCategoryAccess( + task.accountId, + ); + ref.invalidate( + microsoftMasterCategoriesProvider( + task.accountId, + ), + ); + setState(() {}); + } on Object { + setState( + () => error = l10n + .outlookCategoriesUnavailable, + ); + } + }, + child: Text( + l10n.loadOutlookCategories, + ), + ) + : Text(l10n.loadOutlookCategories); + } + return Wrap( + spacing: 6, + runSpacing: 6, + children: [ + for (final category in catalog) + ToggleButton( + checked: _categories( + categories.text, + ).contains(category.displayName), + onChanged: !enabled + ? null + : (selected) { + final names = [ + ..._categories( + categories.text, + ), + ]; + if (selected) { + if (!names.contains( + category.displayName, + )) { + names.add( + category.displayName, + ); + } + } else { + names.remove( + category.displayName, + ); + } + setState( + () => categories.text = + names.join(', '), + ); + }, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (microsoftCategorySwatchArgb( + category.color, + ) + case final swatch?) ...[ + Container( + width: 12, + height: 12, + color: Color(swatch), + ), + const SizedBox(width: 5), + ], + Text(category.displayName), + ], + ), + ), + ], + ); + }, + ), ], if (capabilities.supportsTaskStatus) ...[ const SizedBox(height: 12), @@ -916,7 +1547,10 @@ Future showWindowsTaskDetailsDialog( children: [ TextBox( controller: subtaskTitle, - enabled: !busy && capabilities.canCreateTasks, + enabled: + !busy && + capabilities.canCreateTasks && + !original.googleAssignment.isAssigned, placeholder: l10n.createSubtask, onChanged: (_) => setState(() {}), ), @@ -925,6 +1559,7 @@ Future showWindowsTaskDetailsDialog( onPressed: busy || !capabilities.canCreateTasks || + original.googleAssignment.isAssigned || subtaskTitle.text.trim().isEmpty ? null : () async { @@ -1182,7 +1817,9 @@ Future showWindowsTaskDetailsDialog( builder: (context) => ContentDialog( title: Text(l10n.deleteTask), content: Text( - l10n.deleteTaskConfirmation(task.title), + original.googleAssignment.isAssigned + ? '${l10n.deleteTaskConfirmation(task.title)}\n\n${l10n.deleteAssignedTaskWarning}' + : l10n.deleteTaskConfirmation(task.title), ), actions: [ Button( @@ -1206,7 +1843,12 @@ Future showWindowsTaskDetailsDialog( ); report(onTaskMutationStarted, mutation); try { - await repository.deleteTask(task.sourceId, task.id); + await repository.deleteTask( + task.sourceId, + task.id, + confirmedAssignedSourceDeletion: + original.googleAssignment.isAssigned, + ); report(onTaskMutationCommitted, mutation); changed = true; closeDialog(); diff --git a/test/calendar_providers/calendar_description_test.dart b/test/calendar_providers/calendar_description_test.dart index 7d4a7c9b..b1a5dacd 100644 --- a/test/calendar_providers/calendar_description_test.dart +++ b/test/calendar_providers/calendar_description_test.dart @@ -26,4 +26,108 @@ void main() { ); }, ); + + test( + 'meeting footer preservation replaces user text but retains provider link', + () { + const original = + '

Old agenda

'; + final edited = preserveMicrosoftMeetingBodyHtml( + editedHtml: '
New agenda
', + originalHtml: original, + meetingUrl: 'https://teams.microsoft.com/l/meetup-join/example', + ); + expect(edited, contains('New agenda')); + expect(edited, isNot(contains('Old agenda'))); + expect(edited, contains('Join Microsoft Teams meeting')); + expect( + edited, + contains('https://teams.microsoft.com/l/meetup-join/example'), + ); + }, + ); + + test( + 'nested meeting block is preserved without stale trailing user text', + () { + const block = + '
' + 'Join' + '
Meeting ID: 123
'; + const original = '

Old before

$block

Old after

'; + final edited = preserveMicrosoftMeetingBodyHtml( + editedHtml: '

New before

New after

', + originalHtml: original, + meetingUrl: 'https://teams.microsoft.com/l/meetup-join/example', + ); + expect(edited, contains(block)); + expect(edited, contains('New before')); + expect(edited, contains('New after')); + expect(edited, isNot(contains('Old before'))); + expect(edited, isNot(contains('Old after'))); + expect('Meeting ID: 123'.allMatches(edited), hasLength(1)); + }, + ); + + test('unmatched meeting link rejects unsafe body replacement', () { + expect( + () => preserveMicrosoftMeetingBodyHtml( + editedHtml: '

New agenda

', + originalHtml: '

Old agenda

Meeting ID: 123
', + meetingUrl: 'https://teams.microsoft.com/l/meetup-join/example', + ), + throwsFormatException, + ); + }); + + test('document shell and meeting subtree survive repeated edits once', () { + const meetingUrl = 'https://teams.microsoft.com/l/meetup-join/example'; + const meeting = + '
' + 'Join' + '

Meeting ID: 123

'; + const original = + '' + '

Old before

$meeting

Old after

'; + final first = preserveMicrosoftMeetingBodyHtml( + editedHtml: '

First

', + originalHtml: original, + meetingUrl: meetingUrl, + ); + expect( + first, + startsWith(''), + ); + expect(first, endsWith('')); + expect(meeting.allMatches(first), hasLength(1)); + expect(first, isNot(contains('Old before'))); + expect(first, isNot(contains('Old after'))); + final parts = splitMicrosoftMeetingBodyHtml( + originalHtml: first, + meetingUrl: meetingUrl, + ); + expect(parts.editableHtml, '

First

'); + final second = preserveMicrosoftMeetingBodyHtml( + editedHtml: '

Second

', + originalHtml: first, + meetingUrl: meetingUrl, + ); + expect(meeting.allMatches(second), hasLength(1)); + expect(second, isNot(contains('First'))); + expect(second, contains('Second')); + }); + + test('encoded meeting URL identifies the complete provider fragment', () { + const meetingUrl = 'https://teams.microsoft.com/l/meetup-join/a?x=1&y=2'; + const original = + '

Old

Join

Dial-in

'; + final edited = preserveMicrosoftMeetingBodyHtml( + editedHtml: '

New

', + originalHtml: original, + meetingUrl: meetingUrl, + ); + expect(edited, contains('Dial-in')); + expect(edited, isNot(contains('Old'))); + expect('Join'.allMatches(edited), hasLength(1)); + }); } diff --git a/test/dav/mutation/dav_mutation_patch_test.dart b/test/dav/mutation/dav_mutation_patch_test.dart index 1c671db0..1ba5b05f 100644 --- a/test/dav/mutation/dav_mutation_patch_test.dart +++ b/test/dav/mutation/dav_mutation_patch_test.dart @@ -194,6 +194,84 @@ void main() { expect(result, contains('X-ALARM-UNKNOWN:keep')); }); + test( + 'editing one DAV reminder preserves the other editable alarm verbatim', + () { + final baseline = _baselineEvent.replaceFirst( + 'BEGIN:VALARM\r\nACTION:AUDIO', + 'BEGIN:VALARM\r\nACTION:DISPLAY\r\nTRIGGER:-PT30M\r\n' + 'DESCRIPTION:Second reminder\r\nX-ALARM-KEEP:second\r\nEND:VALARM\r\n' + 'BEGIN:VALARM\r\nACTION:AUDIO', + ); + final patch = buildDavEventUpdatePatch( + target: target, + baselineRawIcs: baseline, + input: DavEventMutationInput( + title: 'Baseline', + allDay: false, + start: DateTime(2026, 8, 3, 9), + end: DateTime(2026, 8, 3, 10), + startTimeZone: 'America/Vancouver', + endTimeZone: 'America/Vancouver', + description: 'Baseline details', + location: 'Room one', + reminders: const { + 'davEditableRows': [ + {'originalIndex': 0, 'minutes': 15}, + {'originalIndex': 1, 'minutes': 45}, + ], + }, + remindersChanged: true, + ), + ); + final result = patch!.applyTo(baseline, nowUtc: DateTime.utc(2026, 8, 8)); + expect(result, contains('TRIGGER:-PT15M')); + expect(result, contains('X-ALARM-UNKNOWN:keep')); + expect(result, contains('TRIGGER:-PT45M')); + expect(result, contains('ACTION:AUDIO')); + }, + ); + + test('DAV reminder removal and addition preserve unrelated alarms', () { + final baseline = _baselineEvent.replaceFirst( + 'BEGIN:VALARM\r\nACTION:AUDIO', + 'BEGIN:VALARM\r\nACTION:DISPLAY\r\nTRIGGER:-PT30M\r\n' + 'DESCRIPTION:Second reminder\r\nX-ALARM-KEEP:second\r\nEND:VALARM\r\n' + 'BEGIN:VALARM\r\nACTION:AUDIO', + ); + String apply(List> rows) => buildDavEventUpdatePatch( + target: target, + baselineRawIcs: baseline, + input: DavEventMutationInput( + title: 'Baseline', + allDay: false, + start: DateTime(2026, 8, 3, 9), + end: DateTime(2026, 8, 3, 10), + startTimeZone: 'America/Vancouver', + endTimeZone: 'America/Vancouver', + description: 'Baseline details', + location: 'Room one', + reminders: {'davEditableRows': rows}, + remindersChanged: true, + ), + )!.applyTo(baseline, nowUtc: DateTime.utc(2026, 8, 8)); + final removed = apply(const [ + {'originalIndex': 1, 'minutes': 30}, + ]); + expect(removed, isNot(contains('TRIGGER:-PT15M'))); + expect(removed, contains('X-ALARM-KEEP:second')); + expect(removed, contains('ACTION:AUDIO')); + final added = apply(const [ + {'originalIndex': 0, 'minutes': 15}, + {'originalIndex': 1, 'minutes': 30}, + {'minutes': 0}, + ]); + expect(added, contains('X-ALARM-UNKNOWN:keep')); + expect(added, contains('X-ALARM-KEEP:second')); + expect(added, contains('TRIGGER:-PT0M')); + expect(added, contains('ACTION:AUDIO')); + }); + test('UTC task dates preserve the entered wall-clock value', () { final object = buildDavTaskObject( const { diff --git a/test/dav/mutation/dav_repository_mutation_integration_test.dart b/test/dav/mutation/dav_repository_mutation_integration_test.dart index ac35ec2c..3822a96a 100644 --- a/test/dav/mutation/dav_repository_mutation_integration_test.dart +++ b/test/dav/mutation/dav_repository_mutation_integration_test.dart @@ -56,6 +56,60 @@ void main() { tearDown(() => database.close()); + for (final remove in [false, true]) { + test( + 'Nextcloud ${remove ? 'removes' : 'adds'} URI ATTACH through DAV queue', + () async { + final baseline = _ical([ + 'BEGIN:VCALENDAR', + 'VERSION:2.0', + 'BEGIN:VEVENT', + 'UID:attachment@example.test', + 'DTSTART:20260810T090000Z', + 'DTEND:20260810T100000Z', + 'SUMMARY:Attachment event', + 'X-CUSTOM;X-KEEP=one:untouched', + 'ATTACH;VALUE=BINARY;ENCODING=BASE64:YWJj', + 'ATTACH;FMTTYPE=application/pdf:https://files.example/old.pdf', + 'END:VEVENT', + 'END:VCALENDAR', + ]); + await _commitObjects(objectRepository, [ + _prepared( + href: '${_collectionHref}attachment.ics', + etag: '"attachment"', + body: baseline, + ), + ]); + final event = await database + .select(database.calendarEvents) + .getSingle(); + final detail = await calendarRepository + .changeNextcloudUriAttachmentReference( + accountId: _accountId, + eventId: event.id, + addUrl: remove ? null : 'https://files.example/new.pdf', + removeUrl: remove ? 'https://files.example/old.pdf' : null, + ); + final operation = await database + .select(database.pendingOps) + .getSingle(); + expect(operation.operationType, 'dav.update'); + final candidate = DavMutationPatch.fromJsonString( + operation.mutationPatchJson!, + ).applyTo(baseline, nowUtc: _now); + expect(candidate, contains('ATTACH;VALUE=BINARY;ENCODING=BASE64:YWJj')); + expect(candidate, contains('X-CUSTOM;X-KEEP=one:untouched')); + expect(candidate.contains('https://files.example/old.pdf'), !remove); + expect(candidate.contains('https://files.example/new.pdf'), !remove); + expect( + jsonEncode(detail!.attachments), + remove ? isNot(contains('old.pdf')) : contains('new.pdf'), + ); + }, + ); + } + test( 'timing-only occurrence exception retains guests, alarms and opaque provider fields', () async { diff --git a/test/dav/mutation/dav_uri_attachment_patch_test.dart b/test/dav/mutation/dav_uri_attachment_patch_test.dart new file mode 100644 index 00000000..5fa2beb9 --- /dev/null +++ b/test/dav/mutation/dav_uri_attachment_patch_test.dart @@ -0,0 +1,108 @@ +import 'package:busymax/src/dav/ical/ical_document.dart'; +import 'package:busymax/src/dav/mutation/dav_projection_mutations.dart'; +import 'package:flutter_test/flutter_test.dart'; + +const _resource = + 'BEGIN:VCALENDAR\r\n' + 'VERSION:2.0\r\n' + 'BEGIN:VTIMEZONE\r\n' + 'TZID:America/Vancouver\r\n' + 'X-TZ-UNKNOWN:keep\r\n' + 'END:VTIMEZONE\r\n' + 'BEGIN:VEVENT\r\n' + 'UID:series@example.com\r\n' + 'DTSTART;TZID=America/Vancouver:20260608T090000\r\n' + 'DTEND;TZID=America/Vancouver:20260608T100000\r\n' + 'SUMMARY:Meeting\r\n' + 'X-CUSTOM;X-PARAM="a,b":untouched\r\n' + 'ATTACH;VALUE=BINARY;ENCODING=BASE64:YWJj\r\n' + 'X-GROUP.ATTACH;FMTTYPE=application/pdf:https://files.example/old.pdf\r\n' + 'BEGIN:VALARM\r\n' + 'ACTION:DISPLAY\r\n' + 'TRIGGER:-PT15M\r\n' + 'END:VALARM\r\n' + 'END:VEVENT\r\n' + 'BEGIN:VEVENT\r\n' + 'UID:series@example.com\r\n' + 'RECURRENCE-ID;TZID=America/Vancouver:20260609T090000\r\n' + 'DTSTART;TZID=America/Vancouver:20260609T110000\r\n' + 'DTEND;TZID=America/Vancouver:20260609T120000\r\n' + 'SUMMARY:Moved meeting\r\n' + 'END:VEVENT\r\n' + 'END:VCALENDAR\r\n'; + +void main() { + const target = IcalComponentKey( + componentType: 'VEVENT', + uid: 'series@example.com', + ); + + test('URI addition preserves binary, grouped and unrelated DAV content', () { + final patch = buildDavUriAttachmentPatch( + baselineRawIcs: _resource, + target: target, + addUrl: 'https://files.example/new.pdf', + )!; + final candidate = patch.applyTo( + _resource, + nowUtc: DateTime.utc(2026, 6, 8), + ); + expect(candidate, contains('ATTACH;VALUE=BINARY;ENCODING=BASE64:YWJj')); + expect( + candidate, + contains( + 'X-GROUP.ATTACH;FMTTYPE=application/pdf:https://files.example/old.pdf', + ), + ); + expect(candidate, contains('X-CUSTOM;X-PARAM="a,b":untouched')); + expect(candidate, contains('X-TZ-UNKNOWN:keep')); + expect(candidate, contains('RECURRENCE-ID;TZID=America/Vancouver')); + expect( + candidate, + contains('ATTACH;VALUE=URI:https://files.example/new.pdf'), + ); + }); + + test('URI removal leaves binary attachment and other resources intact', () { + final patch = buildDavUriAttachmentPatch( + baselineRawIcs: _resource, + target: target, + removeUrl: 'https://files.example/old.pdf', + )!; + final candidate = patch.applyTo( + _resource, + nowUtc: DateTime.utc(2026, 6, 8), + ); + expect(candidate, isNot(contains('https://files.example/old.pdf'))); + expect(candidate, contains('ATTACH;VALUE=BINARY;ENCODING=BASE64:YWJj')); + expect(candidate, contains('BEGIN:VALARM')); + expect(candidate, contains('X-TZ-UNKNOWN:keep')); + }); + + test('URI mutation does not rewrite unchanged or unsupported references', () { + expect( + buildDavUriAttachmentPatch( + baselineRawIcs: _resource, + target: target, + addUrl: 'https://files.example/old.pdf', + ), + isNull, + ); + expect( + buildDavUriAttachmentPatch( + baselineRawIcs: _resource, + target: target, + removeUrl: 'https://files.example/missing.pdf', + ), + isNull, + ); + expect( + () => buildDavUriAttachmentPatch( + baselineRawIcs: _resource, + target: target, + addUrl: 'http://files.example/insecure.pdf', + ), + throwsFormatException, + ); + }); +} diff --git a/test/dav/nextcloud/nextcloud_admin_fixture.dart b/test/dav/nextcloud/nextcloud_admin_fixture.dart index b9793fc2..837f8b67 100644 --- a/test/dav/nextcloud/nextcloud_admin_fixture.dart +++ b/test/dav/nextcloud/nextcloud_admin_fixture.dart @@ -206,6 +206,9 @@ class NextcloudAdminFixture { } if (request.method == 'REPORT' && request.body.contains('principal-property-search')) { + if (request.body.contains('remote.example.test')) { + return http.Response('', 207); + } final group = request.url.path.contains('/groups/'); final path = '/remote.php/dav/principals/${group ? 'groups/team' : 'users/bob'}/'; diff --git a/test/dav/nextcloud/nextcloud_administration_test.dart b/test/dav/nextcloud/nextcloud_administration_test.dart index 8cc93767..b6b594cc 100644 --- a/test/dav/nextcloud/nextcloud_administration_test.dart +++ b/test/dav/nextcloud/nextcloud_administration_test.dart @@ -349,6 +349,64 @@ void main() { expect(requests.every((r) => !r.url.path.contains('/ocs/')), isTrue); }, ); + test('federated calendar share uses local DAV remote-user principal', () async { + const cloudId = 'alice@remote.example.test'; + const principal = + 'principal:principals/remote-users/YWxpY2VAcmVtb3RlLmV4YW1wbGUudGVzdA=='; + final recipient = (await fixture.sharing.search(cloudId)).single; + expect(recipient.federated, isTrue); + expect(recipient.group, isFalse); + expect(recipient.href, principal); + await fixture.sharing.changeShare('collection', recipient, writable: true); + expect(fixture.shares[recipient.href], isTrue); + final post = fixture.requests.singleWhere((r) => r.method == 'POST'); + expect(post.url.host, 'cloud.example.test'); + expect(post.url.path, NextcloudAdminFixture.collection); + expect(post.body, contains('$principal')); + expect(post.body, isNot(contains('%3D'))); + expect( + fixture.requests.every( + (request) => request.url.host == 'cloud.example.test', + ), + isTrue, + ); + }); + + test( + 'committed federated grant remains committed when refresh fails', + () async { + final recipient = (await fixture.sharing.search( + 'alex@remote.example.test', + )).single; + fixture.failRefresh = true; + expect( + await fixture.sharing.changeShare( + 'collection', + recipient, + writable: false, + ), + NextcloudMutationOutcome.refreshPending, + ); + expect(fixture.shares[recipient.href], isFalse); + expect( + fixture.requests.where((request) => request.method == 'POST'), + hasLength(1), + ); + }, + ); + + test('invalid federated identifiers cannot create a share', () async { + for (final value in [ + 'alex@http://remote.example.test', + 'alex@https://user:pass@remote.example.test', + 'alex@remote.example.test?token=secret', + 'alex@localhost', + 'alex@remote.example.test/../other', + ]) { + await expectLater(fixture.sharing.search(value), throwsFormatException); + } + expect(fixture.requests.where((r) => r.method == 'POST'), isEmpty); + }); test( 'publishing rereads the server-selected URL and unpublishing removes it', () async { diff --git a/test/dav/nextcloud/nextcloud_dialogs_test.dart b/test/dav/nextcloud/nextcloud_dialogs_test.dart index 4f1d3b0e..64fb2cf1 100644 --- a/test/dav/nextcloud/nextcloud_dialogs_test.dart +++ b/test/dav/nextcloud/nextcloud_dialogs_test.dart @@ -2,6 +2,7 @@ import 'package:busymax/l10n/generated/app_localizations.dart'; import 'package:busymax/src/app/app_bootstrap.dart'; import 'package:busymax/src/app/busymax_design.dart'; import 'package:busymax/src/app/busymax_yaru_theme.dart'; +import 'package:busymax/src/android/presentation/android_settings_screen.dart'; import 'package:busymax/src/dav/presentation/nextcloud_collection_dialog.dart'; import 'package:busymax/src/ui/windows/windows_nextcloud_dialogs.dart'; import 'package:fluent_ui/fluent_ui.dart' as fluent; @@ -43,6 +44,44 @@ void main() { for (final windows in [false, true]) { final platform = windows ? 'Windows' : 'Linux'; + testWidgets('$platform initiates a federated calendar share', ( + tester, + ) async { + final fixture = NextcloudAdminFixture(); + await tester.runAsync(fixture.seed); + addTearDown(fixture.close); + await _pump(tester, fixture, windows); + const cloudId = 'alex@remote.example.test'; + if (windows) { + final search = find + .byWidgetPredicate( + (widget) => + widget is fluent.TextBox && widget.placeholder == null, + ) + .last; + await tester.enterText(search, cloudId); + } else { + final search = find.byWidgetPredicate( + (widget) => + widget is TextField && + widget.decoration?.labelText == 'Find people or groups', + ); + await tester.enterText(search, cloudId); + } + await tester.ensureVisible(find.text('Search')); + await tester.tap(find.text('Search')); + await _settle(tester); + expect(find.text(cloudId), findsWidgets); + await tester.ensureVisible(find.text('Read-only').last); + await tester.tap(find.text('Read-only').last); + FocusManager.instance.primaryFocus?.unfocus(); + await _waitForShare(tester, fixture); + expect(fixture.shares, hasLength(1)); + expect(fixture.shares.values.single, isFalse); + expect(fixture.requests.where((r) => r.method == 'POST'), hasLength(1)); + expect(tester.takeException(), isNull); + }); + testWidgets( '$platform can save metadata on a content-read-only collection', (tester) async { @@ -110,6 +149,52 @@ void main() { }); } + testWidgets('Android initiates a federated calendar share', (tester) async { + final fixture = NextcloudAdminFixture(); + await tester.runAsync(fixture.seed); + addTearDown(fixture.close); + await tester.binding.setSurfaceSize(const Size(430, 800)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + await tester.pumpWidget( + ProviderScope( + overrides: [ + localSettingsStoreProvider.overrideWithValue(MemorySettingsStore()), + nextcloudSharingServiceProvider( + 'account', + ).overrideWithValue(fixture.sharing), + ], + child: localizedTestApp( + child: const AndroidNextcloudSharingScreen( + accountId: 'account', + collectionId: 'collection', + title: 'Work', + ), + ), + ), + ); + await _settle(tester); + const cloudId = 'alex@remote.example.test'; + await tester.enterText( + find.byWidgetPredicate( + (widget) => + widget is TextField && + widget.decoration?.labelText == 'Find people or groups', + ), + cloudId, + ); + await tester.tap(find.byIcon(Icons.search).last); + await _settle(tester); + expect(find.text(cloudId), findsWidgets); + await tester.tap(find.byType(PopupMenuButton).last); + await tester.pumpAndSettle(); + await tester.tap(find.text('Read-only').last); + FocusManager.instance.primaryFocus?.unfocus(); + await _waitForShare(tester, fixture); + expect(fixture.shares, hasLength(1)); + expect(fixture.shares.values.single, isFalse); + expect(tester.takeException(), isNull); + }); + testWidgets('nested collection confirmation keeps the parent modal active', ( tester, ) async { @@ -288,6 +373,24 @@ Future _settle(WidgetTester tester) async { await tester.pumpAndSettle(); } +Future _waitForShare( + WidgetTester tester, + NextcloudAdminFixture fixture, +) async { + for (var i = 0; i < 30 && fixture.shares.isEmpty; i++) { + await tester.runAsync( + () => Future.delayed(const Duration(milliseconds: 20)), + ); + await tester.pump(const Duration(milliseconds: 100)); + } + for (var i = 0; i < 10; i++) { + await tester.runAsync( + () => Future.delayed(const Duration(milliseconds: 20)), + ); + await tester.pump(const Duration(milliseconds: 100)); + } +} + Future _pump( WidgetTester tester, NextcloudAdminFixture fixture, diff --git a/test/features/calendar/data/calendar_repository_test.dart b/test/features/calendar/data/calendar_repository_test.dart index ca2f5190..3b8cf906 100644 --- a/test/features/calendar/data/calendar_repository_test.dart +++ b/test/features/calendar/data/calendar_repository_test.dart @@ -10,10 +10,12 @@ 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/calendar/data/calendar_event_detail.dart'; import 'package:busymax/src/features/calendar/domain/event_timing_policy.dart'; +import 'package:busymax/src/features/calendar/domain/google_status_event.dart'; import 'package:busymax/src/features/maps/data/location_resolution_repository.dart'; import 'package:busymax/src/features/maps/domain/geographic_point.dart'; import 'package:busymax/src/features/maps/domain/location_result.dart'; import 'package:busymax/src/core/time/provider_date_time.dart'; +import 'package:busymax/src/microsoft_calendar/microsoft_shared_calendar_address.dart'; import 'package:busymax/src/schedule/schedule_event_rescheduling.dart'; import 'package:busymax/src/schedule/schedule_item.dart'; import 'package:busymax/src/schedule/schedule_range.dart'; @@ -1341,6 +1343,362 @@ void main() { expect(event.recurrenceJson, jsonEncode(recurrence)); }); + test( + 'Google label edits and clearing retain unrelated raw fields locally', + () async { + await _upsertSource(repository); + await repository.upsertEvent( + accountId: 'google:g', + event: const CalendarEventDto( + provider: BusyProvider.google, + providerCalendarId: 'calendar-1', + providerEventId: 'labelled', + title: 'Planning', + startDateTime: '2026-06-08T09:00:00Z', + endDateTime: '2026-06-08T10:00:00Z', + organizerJson: {'self': true}, + rawJson: { + 'id': 'labelled', + 'eventLabelId': 'old-label', + 'unrelated': {'keep': true}, + 'organizer': {'self': true}, + }, + ), + ); + final id = CalendarRepository.eventId( + accountId: 'google:g', + provider: BusyProvider.google, + providerCalendarId: 'calendar-1', + providerEventId: 'labelled', + ); + final detail = (await repository.loadEventDetail(id))!; + expect( + EventEditorDraft.fromEventDetail(detail).eventLabelId, + 'old-label', + ); + await repository.updateLocalEvent( + EventEditorDraft.fromEventDetail(detail).copyWith(title: 'Updated'), + ); + var ops = await database.select(database.pendingOps).get(); + expect(jsonDecode(ops.last.requestJson), isNot(contains('eventLabelId'))); + await repository.updateLocalEvent( + EventEditorDraft.fromEventDetail( + (await repository.loadEventDetail(id))!, + ).copyWith(eventLabelId: 'new-label'), + ); + ops = await database.select(database.pendingOps).get(); + expect(jsonDecode(ops.last.requestJson)['eventLabelId'], 'new-label'); + var reopened = (await repository.loadEventDetail(id))!; + expect((reopened.raw as Map)['eventLabelId'], 'new-label'); + expect((reopened.raw as Map)['unrelated'], {'keep': true}); + await repository.updateLocalEvent( + EventEditorDraft.fromEventDetail( + reopened, + ).copyWith(clearEventLabelId: true), + ); + ops = await database.select(database.pendingOps).get(); + expect(jsonDecode(ops.last.requestJson)['eventLabelId'], ''); + reopened = (await repository.loadEventDetail(id))!; + expect((reopened.raw as Map).containsKey('eventLabelId'), isFalse); + expect((reopened.raw as Map)['unrelated'], {'keep': true}); + }, + ); + + test( + 'Google status creation projects type and properties into queue and detail', + () async { + await repository.upsertSource( + accountId: 'google:g', + source: const CalendarSourceDto( + provider: BusyProvider.google, + providerCalendarId: 'calendar-1', + summary: 'Primary', + primaryCalendar: true, + ), + ); + for (final type in const [ + 'focusTime', + 'outOfOffice', + 'workingLocation', + ]) { + final properties = defaultGoogleStatusProperties(type); + await repository.createLocalEvent( + EventEditorDraft.newEvent( + accountId: 'google:g', + sourceId: CalendarRepository.sourceId( + accountId: 'google:g', + provider: BusyProvider.google, + providerCalendarId: 'calendar-1', + ), + providerCalendarId: 'calendar-1', + start: DateTime.utc(2026, 6, 8, 9), + end: DateTime.utc(2026, 6, 8, 10), + ).copyWith( + title: 'Status', + eventType: type, + googleStatusProperties: properties, + showAs: type == 'workingLocation' ? 'transparent' : 'opaque', + visibilityOrSensitivity: type == 'workingLocation' + ? 'public' + : 'default', + ), + ); + final event = + (await database.select(database.calendarEvents).get()).last; + final operation = + (await database.select(database.pendingOps).get()).last; + final request = jsonDecode(operation.requestJson) as Map; + expect(request['eventType'], type); + expect(request['googleStatusProperties'], properties); + expect((event.rawJson), contains('"eventType":"$type"')); + expect( + EventEditorDraft.fromEventDetail( + (await repository.loadEventDetail(event.id))!, + ).googleStatusProperties, + properties, + ); + } + }, + ); + + test( + 'existing Google status edit queues context, not type conversion', + () async { + await repository.upsertSource( + accountId: 'google:g', + source: const CalendarSourceDto( + provider: BusyProvider.google, + providerCalendarId: 'calendar-1', + summary: 'Primary', + primaryCalendar: true, + ), + ); + await repository.upsertEvent( + accountId: 'google:g', + event: const CalendarEventDto( + provider: BusyProvider.google, + providerCalendarId: 'calendar-1', + providerEventId: 'focus-existing', + title: 'Focus', + eventType: 'focusTime', + startDateTime: '2026-06-08T09:00:00Z', + startTimeZone: 'UTC', + endDateTime: '2026-06-08T10:00:00Z', + endTimeZone: 'UTC', + visibility: 'default', + transparencyOrShowAs: 'opaque', + organizerJson: {'self': true}, + rawJson: { + 'id': 'focus-existing', + 'summary': 'Focus', + 'eventType': 'focusTime', + 'focusTimeProperties': { + 'autoDeclineMode': 'declineNone', + 'chatStatus': 'available', + }, + 'organizer': {'self': true}, + }, + ), + ); + final id = CalendarRepository.eventId( + accountId: 'google:g', + provider: BusyProvider.google, + providerCalendarId: 'calendar-1', + providerEventId: 'focus-existing', + ); + await repository.updateLocalEvent( + EventEditorDraft.fromEventDetail( + (await repository.loadEventDetail(id))!, + ).copyWith(title: 'Focus renamed'), + ); + var request = + jsonDecode( + (await database.select(database.pendingOps).get()) + .last + .requestJson, + ) + as Map; + expect(request, isNot(contains('googleStatusProperties'))); + expect(request, isNot(contains(calendarEventGoogleStatusTypeContextKey))); + await repository.updateLocalEvent( + EventEditorDraft.fromEventDetail( + (await repository.loadEventDetail(id))!, + ).copyWith( + googleStatusProperties: { + 'autoDeclineMode': 'declineAllConflictingInvitations', + 'chatStatus': 'available', + }, + ), + ); + request = + jsonDecode( + (await database.select(database.pendingOps).get()) + .last + .requestJson, + ) + as Map; + expect(request[calendarEventGoogleStatusTypeContextKey], 'focusTime'); + expect(request, isNot(contains('eventType'))); + expect(request, isNot(contains('providerRaw'))); + }, + ); + + test( + 'Google status creation on a secondary calendar leaves no local mutation', + () async { + await _upsertSource(repository); + final source = + (await database.select(database.calendarSources).get()).single; + await expectLater( + repository.createLocalEvent( + EventEditorDraft.newEvent( + accountId: 'google:g', + sourceId: source.id, + providerCalendarId: source.providerCalendarId, + start: DateTime.utc(2026, 6, 8, 9), + end: DateTime.utc(2026, 6, 8, 10), + ).copyWith( + title: 'Focus', + eventType: 'focusTime', + googleStatusProperties: defaultGoogleStatusProperties('focusTime'), + showAs: 'opaque', + ), + ), + throwsUnsupportedError, + ); + expect(await database.select(database.calendarEvents).get(), isEmpty); + expect(await database.select(database.pendingOps).get(), isEmpty); + }, + ); + + test( + 'existing Gmail event queues only supported color and reminder edits', + () async { + await _upsertSource(repository); + await repository.upsertEvent( + accountId: 'google:g', + event: const CalendarEventDto( + provider: BusyProvider.google, + providerCalendarId: 'calendar-1', + providerEventId: 'gmail-event', + title: 'Flight', + eventType: 'fromGmail', + colorId: '1', + startDateTime: '2026-06-08T09:00:00Z', + endDateTime: '2026-06-08T10:00:00Z', + organizerJson: {'self': true}, + remindersJson: {'useDefault': true}, + rawJson: { + 'id': 'gmail-event', + 'summary': 'Flight', + 'eventType': 'fromGmail', + 'organizer': {'self': true}, + 'reminders': {'useDefault': true}, + 'colorId': '1', + }, + ), + ); + final id = CalendarRepository.eventId( + accountId: 'google:g', + provider: BusyProvider.google, + providerCalendarId: 'calendar-1', + providerEventId: 'gmail-event', + ); + final initial = EventEditorDraft.fromEventDetail( + (await repository.loadEventDetail(id))!, + ); + await repository.updateLocalEvent(initial); + expect(await database.select(database.pendingOps).get(), isEmpty); + await repository.updateLocalEvent(initial.copyWith(colorId: '2')); + var request = + jsonDecode( + (await database.select(database.pendingOps).get()) + .last + .requestJson, + ) + as Map; + expect(request['colorId'], '2'); + expect(request, isNot(contains('eventType'))); + await repository.updateLocalEvent( + EventEditorDraft.fromEventDetail( + (await repository.loadEventDetail(id))!, + ).copyWith( + reminders: const { + 'useDefault': false, + 'overrides': [ + {'method': 'popup', 'minutes': 10}, + ], + }, + remindersChanged: true, + ), + ); + request = + jsonDecode( + (await database.select(database.pendingOps).get()) + .last + .requestJson, + ) + as Map; + expect((request['remindersJson'] as Map)['useDefault'], false); + final count = (await database.select(database.pendingOps).get()).length; + await expectLater( + repository.updateLocalEvent( + EventEditorDraft.fromEventDetail( + (await repository.loadEventDetail(id))!, + ).copyWith(title: 'Changed flight'), + ), + throwsA(isA()), + ); + expect((await database.select(database.pendingOps).get()).length, count); + expect((await repository.loadEventDetail(id))!.title, 'Flight'); + await expectLater( + repository.updateLocalEvent( + EventEditorDraft.fromEventDetail( + (await repository.loadEventDetail(id))!, + ).copyWith(eventType: 'default'), + ), + throwsUnsupportedError, + ); + await (database.update(database.calendarSources) + ..where((row) => row.id.equals(_sourceId))) + .write(const CalendarSourcesCompanion(readOnly: Value(true))); + await expectLater( + repository.updateLocalEvent( + EventEditorDraft.fromEventDetail( + (await repository.loadEventDetail(id))!, + ).copyWith(colorId: '3'), + ), + throwsA(isA()), + ); + await (database.update(database.calendarSources) + ..where((row) => row.id.equals(_sourceId))) + .write(const CalendarSourcesCompanion(readOnly: Value(false))); + final local = + (await database.select(database.calendarEvents).get()).single; + await (database.update( + database.calendarEvents, + )..where((row) => row.id.equals(id))).write( + CalendarEventsCompanion( + rawJson: Value( + jsonEncode({ + ...jsonDecode(local.rawJson!) as Map, + 'locked': true, + }), + ), + ), + ); + await expectLater( + repository.updateLocalEvent( + EventEditorDraft.fromEventDetail( + (await repository.loadEventDetail(id))!, + ).copyWith(colorId: '3'), + ), + throwsA(isA()), + ); + expect((await database.select(database.pendingOps).get()).length, count); + }, + ); + test('title-only Google edit preserves offset-only timestamps', () async { await _upsertSource(repository); await repository.upsertEvent( @@ -1818,6 +2176,57 @@ void main() { ); }); + test( + 'opened Microsoft owner calendar removes only the local source', + () async { + final calendarId = const MicrosoftSharedPrimaryCalendarAddress( + owner: 'owner@example.test', + graphCalendarId: 'owner-primary', + ).sourceCalendarId; + final sourceId = CalendarRepository.sourceId( + accountId: 'google:g', + provider: BusyProvider.microsoft, + providerCalendarId: calendarId, + ); + await repository.upsertSource( + accountId: 'google:g', + source: CalendarSourceDto( + provider: BusyProvider.microsoft, + providerCalendarId: calendarId, + summary: 'Opened', + readOnly: false, + isRemovable: true, + ), + ); + final source = (await repository.watchSourcesForAccounts([ + 'google:g', + ]).first).single; + expect(source.capabilities.canRemoveCalendar, isTrue); + expect(source.capabilities.canRenameCalendar, isFalse); + expect(source.capabilities.canChangeCalendarColor, isFalse); + await repository.deleteLocalSource(sourceId); + expect(await database.select(database.pendingOps).get(), isEmpty); + expect( + (await database.select(database.calendarSources).getSingle()).isDeleted, + isTrue, + ); + await repository.upsertSource( + accountId: 'google:g', + source: CalendarSourceDto( + provider: BusyProvider.microsoft, + providerCalendarId: calendarId, + summary: 'Opened', + readOnly: false, + isRemovable: true, + ), + ); + expect( + (await database.select(database.calendarSources).getSingle()).isDeleted, + isTrue, + ); + }, + ); + test('provider hidden state can return to visible', () async { await repository.upsertSource( accountId: 'google:g', diff --git a/test/features/calendar/data/cloud_calendar_sharing_service_test.dart b/test/features/calendar/data/cloud_calendar_sharing_service_test.dart new file mode 100644 index 00000000..4c959034 --- /dev/null +++ b/test/features/calendar/data/cloud_calendar_sharing_service_test.dart @@ -0,0 +1,2279 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:busymax/src/features/calendar/data/calendar_repository.dart'; +import 'package:busymax/src/app/busymax_design.dart'; +import 'package:busymax/src/features/calendar/data/cloud_calendar_sharing_service.dart'; +import 'package:busymax/src/android/presentation/android_cloud_calendar_sharing_content.dart'; +import 'package:busymax/src/core/http/request_dispatch_exception.dart'; +import 'package:busymax/src/core/auth/oauth_models.dart'; +import 'package:busymax/src/features/connectivity/network_connectivity_service.dart'; +import 'package:busymax/src/features/calendar/presentation/cloud_calendar_sharing_content.dart' + as linux; +import 'package:busymax/src/google_calendar/google_calendar_api_client.dart'; +import 'package:busymax/src/google_calendar/google_calendar_errors.dart'; +import 'package:busymax/src/microsoft_calendar/microsoft_calendar_api_client.dart'; +import 'package:busymax/src/microsoft_calendar/microsoft_calendar_errors.dart'; +import 'package:busymax/src/providers/busy_provider.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; + +import '../../../test_localized_app.dart'; + +void main() { + const googleSource = CalendarSourceEntity( + id: 'source', + accountId: 'account', + provider: BusyProvider.google, + providerCalendarId: 'calendar@example.com', + summary: 'Calendar', + selected: true, + hidden: false, + readOnly: false, + isDeleted: false, + accessRole: 'owner', + ); + + test('only actual manageable source roles expose cloud sharing', () { + expect(CloudCalendarSharingService.canManageSource(googleSource), isTrue); + expect( + CloudCalendarSharingService.canManageSource( + const CalendarSourceEntity( + id: 'reader', + accountId: 'account', + provider: BusyProvider.google, + providerCalendarId: 'shared', + summary: 'Shared', + selected: true, + hidden: false, + readOnly: false, + isDeleted: false, + accessRole: 'writer', + ), + ), + isFalse, + ); + }); + + test( + 'pre-dispatch add failure leaves sharing usable without reconciliation', + () async { + const source = CalendarSourceEntity( + id: 'unsent-add-source', + accountId: 'unsent-add-account', + provider: BusyProvider.google, + providerCalendarId: 'unsent-add-calendar', + summary: 'Calendar', + selected: true, + hidden: false, + readOnly: false, + isDeleted: false, + accessRole: 'owner', + ); + var offline = false; + var providerMutations = 0; + final client = GoogleCalendarApiClient( + httpClient: ConnectivityAwareHttpClient( + inner: MockClient((request) async { + if (request.method == 'GET') { + return http.Response(jsonEncode({'items': []}), 200); + } + providerMutations++; + return http.Response( + jsonEncode({ + 'id': 'grant-unsent', + 'role': 'reader', + 'scope': {'type': 'user', 'value': 'friend@example.com'}, + }), + 200, + ); + }), + requireNetwork: () async { + if (offline) throw const NetworkUnavailableException(); + }, + ), + baseUri: Uri.parse('https://www.googleapis.com'), + ); + final service = CloudCalendarSharingService( + source: source, + google: client, + ); + expect((await service.load()).grants, isEmpty); + offline = true; + await expectLater( + service.add(recipient: 'friend@example.com', role: 'reader'), + throwsA(isA()), + ); + expect(providerMutations, 0); + expect(service.hasUnresolvedOutcome, isFalse); + offline = false; + expect((await service.load()).outcomeUnknown, isFalse); + await service.add(recipient: 'friend@example.com', role: 'reader'); + expect(providerMutations, 1); + }, + ); + + test('calendar authorization failure cannot strand sharing intent', () async { + const source = CalendarSourceEntity( + id: 'auth-unsent-source', + accountId: 'auth-unsent-account', + provider: BusyProvider.google, + providerCalendarId: 'auth-unsent-calendar', + summary: 'Calendar', + selected: true, + hidden: false, + readOnly: false, + isDeleted: false, + accessRole: 'owner', + ); + const failure = OAuthException('TokenUnavailable', 'Token unavailable'); + var failAuthorization = false; + var mutations = 0; + final client = GoogleCalendarApiClient( + httpClient: MockClient((request) async { + if (request.method == 'GET') { + return http.Response(jsonEncode({'items': []}), 200); + } + mutations++; + return http.Response( + jsonEncode({ + 'id': 'auth-grant', + 'role': 'reader', + 'scope': {'type': 'user', 'value': 'friend@example.test'}, + }), + 200, + ); + }), + baseUri: Uri.parse('https://www.googleapis.com'), + authorizationHeaderProvider: () async { + if (failAuthorization) throw failure; + return 'Bearer token'; + }, + ); + final service = CloudCalendarSharingService(source: source, google: client); + expect((await service.load()).grants, isEmpty); + failAuthorization = true; + await expectLater( + service.add(recipient: 'friend@example.test', role: 'reader'), + throwsA(isA()), + ); + expect(mutations, 0); + expect(service.hasUnresolvedOutcome, isFalse); + failAuthorization = false; + expect((await service.load()).outcomeUnknown, isFalse); + await service.add(recipient: 'friend@example.test', role: 'reader'); + expect(mutations, 1); + }); + + for (final provider in [BusyProvider.google, BusyProvider.microsoft]) { + test( + '$provider rejected 401 and failed refresh permits a safe sharing retry', + () async { + final google = provider == BusyProvider.google; + final source = CalendarSourceEntity( + id: 'auth-401-$provider', + accountId: 'auth-401-account-$provider', + provider: provider, + providerCalendarId: 'auth-401-calendar-$provider', + summary: 'Calendar', + selected: true, + hidden: false, + readOnly: false, + isDeleted: false, + accessRole: 'owner', + primaryCalendar: !google, + ); + const failure = OAuthRefreshException( + 'RefreshDenied', + 'Refresh denied', + statusCode: 400, + ); + var recovered = false; + var committed = false; + var mutationRequests = 0; + var refreshes = 0; + final httpClient = MockClient((request) async { + if (request.method == 'GET') { + return http.Response( + jsonEncode( + google + ? { + 'items': committed + ? [ + { + 'id': 'auth-401-grant', + 'role': 'reader', + 'scope': { + 'type': 'user', + 'value': 'friend@example.test', + }, + }, + ] + : [], + } + : { + 'value': committed + ? [ + { + 'id': 'auth-401-grant', + 'role': 'read', + 'allowedRoles': ['read', 'write'], + 'isRemovable': true, + 'emailAddress': { + 'address': 'friend@example.test', + }, + }, + ] + : [], + }, + ), + 200, + ); + } + mutationRequests++; + if (!recovered) return http.Response('{}', 401); + committed = true; + return http.Response( + jsonEncode( + google + ? { + 'id': 'auth-401-grant', + 'role': 'reader', + 'scope': {'type': 'user', 'value': 'friend@example.test'}, + } + : { + 'id': 'auth-401-grant', + 'role': 'read', + 'allowedRoles': ['read', 'write'], + 'isRemovable': true, + 'emailAddress': {'address': 'friend@example.test'}, + }, + ), + 201, + ); + }); + Future refresh() async { + refreshes++; + throw failure; + } + + CloudCalendarSharingService create() => CloudCalendarSharingService( + source: source, + google: google + ? GoogleCalendarApiClient( + httpClient: httpClient, + baseUri: Uri.parse('https://www.googleapis.com'), + authorizationHeaderProvider: () async => 'Bearer google', + unauthorizedRefreshProvider: refresh, + ) + : null, + microsoft: google + ? null + : MicrosoftCalendarApiClient( + httpClient: httpClient, + baseUri: Uri.parse('https://graph.microsoft.com/v1.0'), + responseTimeZone: 'UTC', + authorizationHeaderProvider: () async => 'Bearer microsoft', + unauthorizedRefreshProvider: refresh, + ), + ); + final service = create(); + expect((await service.load()).grants, isEmpty); + await expectLater( + service.add( + recipient: 'friend@example.test', + role: google ? 'reader' : 'read', + ), + throwsA( + isA() + .having( + (error) => error.kind, + 'kind', + RequestPreDispatchFailureKind.authentication, + ) + .having((error) => error.cause, 'cause', same(failure)), + ), + ); + expect(mutationRequests, 1); + expect(refreshes, 1); + expect(committed, isFalse); + expect(service.hasUnresolvedOutcome, isFalse); + recovered = true; + final reopened = create(); + expect((await reopened.load()).grants, isEmpty); + final result = await reopened.add( + recipient: 'friend@example.test', + role: google ? 'reader' : 'read', + ); + expect(result.outcomeUnknown, isFalse); + expect(result.grants.single.recipient, 'friend@example.test'); + expect(mutationRequests, 2); + expect(refreshes, 1); + }, + ); + } + + for (final provider in [BusyProvider.google, BusyProvider.microsoft]) { + for (final status in [408, 409, 503]) { + test( + '$provider ambiguous status $status retains sharing intent', + () async { + final google = provider == BusyProvider.google; + final source = CalendarSourceEntity( + id: 'ambiguous-$provider-$status', + accountId: 'ambiguous-account-$provider-$status', + provider: provider, + providerCalendarId: 'ambiguous-calendar-$provider-$status', + summary: 'Calendar', + selected: true, + hidden: false, + readOnly: false, + isDeleted: false, + accessRole: 'owner', + primaryCalendar: !google, + ); + var mutationRequests = 0; + final httpClient = MockClient((request) async { + if (request.method == 'GET') { + return mutationRequests == 0 + ? http.Response( + jsonEncode( + google ? {'items': []} : {'value': []}, + ), + 200, + ) + : http.Response('permission list unavailable', 503); + } + mutationRequests++; + return http.Response('{}', status); + }); + final service = CloudCalendarSharingService( + source: source, + google: google + ? GoogleCalendarApiClient( + httpClient: httpClient, + baseUri: Uri.parse('https://www.googleapis.com'), + ) + : null, + microsoft: google + ? null + : MicrosoftCalendarApiClient( + httpClient: httpClient, + baseUri: Uri.parse('https://graph.microsoft.com/v1.0'), + responseTimeZone: 'UTC', + ), + ); + expect((await service.load()).grants, isEmpty); + final result = await service.add( + recipient: 'friend@example.test', + role: google ? 'reader' : 'read', + ); + expect(result.outcomeUnknown, isTrue); + expect(result.refreshError, isNotNull); + expect(service.hasUnresolvedOutcome, isTrue); + await expectLater( + service.add( + recipient: 'friend@example.test', + role: google ? 'reader' : 'read', + ), + throwsStateError, + ); + expect(mutationRequests, 1); + }, + ); + } + } + + for (final provider in [BusyProvider.google, BusyProvider.microsoft]) { + test( + '$provider rejected sharing throttle is not unresolved after an unchanged read', + () async { + final google = provider == BusyProvider.google; + final source = CalendarSourceEntity( + id: 'throttle-regression-$provider', + accountId: 'throttle-regression-account-$provider', + provider: provider, + providerCalendarId: 'throttle-regression-calendar-$provider', + summary: 'Calendar', + selected: true, + hidden: false, + readOnly: false, + isDeleted: false, + accessRole: 'owner', + primaryCalendar: !google, + ); + var writes = 0; + var failRead = false; + final client = MockClient((request) async { + if (request.method == 'GET') { + if (failRead) return http.Response('read unavailable', 503); + return http.Response( + jsonEncode( + google ? {'items': []} : {'value': []}, + ), + 200, + ); + } + writes++; + return http.Response( + jsonEncode( + google + ? { + 'error': { + 'code': 429, + 'message': 'Rate limited', + 'errors': [ + { + 'domain': 'usageLimits', + 'reason': 'rateLimitExceeded', + }, + ], + }, + } + : { + 'error': { + 'code': 'TooManyRequests', + 'message': 'Throttled', + }, + }, + ), + 429, + headers: {'retry-after': '30'}, + ); + }); + final service = CloudCalendarSharingService( + source: source, + google: google + ? GoogleCalendarApiClient( + httpClient: client, + baseUri: Uri.parse('https://www.googleapis.com'), + ) + : null, + microsoft: google + ? null + : MicrosoftCalendarApiClient( + httpClient: client, + baseUri: Uri.parse('https://graph.microsoft.com/v1.0'), + responseTimeZone: 'UTC', + ), + ); + expect((await service.load()).grants, isEmpty); + await expectLater( + service.add( + recipient: 'friend@example.test', + role: google ? 'reader' : 'read', + ), + throwsA( + google + ? isA() + : isA(), + ), + ); + failRead = true; + await expectLater(service.load(), throwsA(anything)); + expect(service.hasUnresolvedOutcome, isFalse); + failRead = false; + expect((await service.load()).grants, isEmpty); + expect(service.hasUnresolvedOutcome, isFalse); + expect(writes, 1); + }, + ); + } + + for (final provider in [BusyProvider.google, BusyProvider.microsoft]) { + for (final operation in ['add', 'change', 'revoke']) { + for (final status + in provider == BusyProvider.google ? [403, 429] : [429]) { + test( + '$provider $operation $status throttle permits only an explicit eligible retry', + () async { + final google = provider == BusyProvider.google; + final initialRole = google ? 'reader' : 'read'; + final changedRole = google ? 'writer' : 'write'; + final reason = operation == 'change' + ? 'userRateLimitExceeded' + : 'rateLimitExceeded'; + final source = CalendarSourceEntity( + id: 'throttle-$provider-$operation-$status', + accountId: 'throttle-account-$provider-$operation-$status', + provider: provider, + providerCalendarId: + 'throttle-calendar-$provider-$operation-$status', + summary: 'Calendar', + selected: true, + hidden: false, + readOnly: false, + isDeleted: false, + accessRole: 'owner', + primaryCalendar: !google, + ); + var clock = DateTime.utc(2026, 1, 1); + String? remoteRole = operation == 'add' ? null : initialRole; + var writes = 0; + var reads = 0; + final transport = MockClient((request) async { + if (request.method == 'GET') { + reads++; + return http.Response( + jsonEncode( + google + ? { + 'items': remoteRole == null + ? [] + : [ + { + 'id': 'throttle-grant', + 'role': remoteRole, + 'scope': { + 'type': 'user', + 'value': 'friend@example.test', + }, + }, + ], + } + : { + 'value': remoteRole == null + ? [] + : [ + { + 'id': 'throttle-grant', + 'role': remoteRole, + 'allowedRoles': ['read', 'write'], + 'isRemovable': true, + 'emailAddress': { + 'address': 'friend@example.test', + }, + }, + ], + }, + ), + 200, + ); + } + writes++; + if (writes == 1) { + return http.Response( + jsonEncode( + google + ? { + 'error': { + 'code': status, + 'message': 'Quota', + 'errors': [ + {'domain': 'usageLimits', 'reason': reason}, + ], + }, + } + : { + 'error': { + 'code': 'TooManyRequests', + 'message': 'Throttled', + }, + }, + ), + status, + headers: {'retry-after': '17'}, + ); + } + remoteRole = switch (operation) { + 'add' => initialRole, + 'change' => changedRole, + _ => null, + }; + if (request.method == 'DELETE') return http.Response('', 204); + return http.Response( + jsonEncode( + google + ? { + 'id': 'throttle-grant', + 'role': remoteRole, + 'scope': { + 'type': 'user', + 'value': 'friend@example.test', + }, + } + : { + 'id': 'throttle-grant', + 'role': remoteRole, + 'allowedRoles': ['read', 'write'], + 'isRemovable': true, + 'emailAddress': {'address': 'friend@example.test'}, + }, + ), + request.method == 'POST' ? 201 : 200, + ); + }); + CloudCalendarSharingService create() => CloudCalendarSharingService( + source: source, + now: () => clock, + google: google + ? GoogleCalendarApiClient( + httpClient: transport, + baseUri: Uri.parse('https://www.googleapis.com'), + ) + : null, + microsoft: google + ? null + : MicrosoftCalendarApiClient( + httpClient: transport, + baseUri: Uri.parse('https://graph.microsoft.com/v1.0'), + responseTimeZone: 'UTC', + ), + ); + Future mutate( + CloudCalendarSharingService service, + CloudCalendarShareSnapshot snapshot, + ) => switch (operation) { + 'add' => service.add( + recipient: 'friend@example.test', + role: initialRole, + ), + 'change' => service.change(snapshot.grants.single, changedRole), + _ => service.revoke(snapshot.grants.single), + }; + final service = create(); + final baseline = await service.load(); + expect(baseline.grants.singleOrNull?.role, remoteRole); + await expectLater( + mutate(service, baseline), + throwsA( + google + ? isA() + .having((e) => e.isRateLimited, 'rate limited', true) + .having((e) => e.reasons, 'reasons', contains(reason)) + .having( + (e) => e.retryAfter, + 'retry-after', + const Duration(seconds: 17), + ) + : isA() + .having((e) => e.isRateLimited, 'rate limited', true) + .having( + (e) => e.retryAfter, + 'retry-after', + const Duration(seconds: 17), + ), + ), + ); + expect(writes, 1); + expect(remoteRole, operation == 'add' ? null : initialRole); + expect(service.hasUnresolvedOutcome, isFalse); + expect(service.isRateLimited, isTrue); + final unchanged = await service.load(); + expect(unchanged.outcomeUnknown, isFalse); + expect(unchanged.grants.singleOrNull?.role, remoteRole); + final reopened = create(); + final reopenedSnapshot = await reopened.load(); + expect(reopened.isRateLimited, isTrue); + await expectLater( + mutate(reopened, reopenedSnapshot), + throwsA(isA()), + ); + expect(writes, 1); + clock = clock.add(const Duration(seconds: 16)); + expect(reopened.isRateLimited, isTrue); + clock = clock.add(const Duration(seconds: 1)); + expect(reopened.isRateLimited, isFalse); + expect(writes, 1); + final result = await mutate(reopened, reopenedSnapshot); + expect(result.outcomeUnknown, isFalse); + expect(result.grants.singleOrNull?.role, remoteRole); + expect(writes, 2); + expect(reads, greaterThanOrEqualTo(3)); + }, + ); + } + } + } + + for (final provider in [BusyProvider.google, BusyProvider.microsoft]) { + test( + '$provider repeated throttles back off without a usable header', + () async { + final google = provider == BusyProvider.google; + final source = CalendarSourceEntity( + id: 'backoff-$provider', + accountId: 'backoff-account-$provider', + provider: provider, + providerCalendarId: 'backoff-calendar-$provider', + summary: 'Calendar', + selected: true, + hidden: false, + readOnly: false, + isDeleted: false, + accessRole: 'owner', + primaryCalendar: !google, + ); + var clock = DateTime.utc(2026, 1, 1); + var writes = 0; + var committed = false; + final transport = MockClient((request) async { + if (request.method == 'GET') { + return http.Response( + jsonEncode( + google + ? { + 'items': committed + ? [ + { + 'id': 'backoff-grant', + 'role': 'reader', + 'scope': { + 'type': 'user', + 'value': 'friend@example.test', + }, + }, + ] + : [], + } + : { + 'value': committed + ? [ + { + 'id': 'backoff-grant', + 'role': 'read', + 'allowedRoles': ['read', 'write'], + 'isRemovable': true, + 'emailAddress': { + 'address': 'friend@example.test', + }, + }, + ] + : [], + }, + ), + 200, + ); + } + writes++; + if (writes < 3) { + return http.Response( + jsonEncode( + google + ? { + 'error': { + 'code': 429, + 'errors': [ + {'reason': 'rateLimitExceeded'}, + ], + }, + } + : { + 'error': {'code': 'TooManyRequests'}, + }, + ), + 429, + headers: writes == 1 ? {'retry-after': 'invalid'} : {}, + ); + } + committed = true; + return http.Response( + jsonEncode( + google + ? { + 'id': 'backoff-grant', + 'role': 'reader', + 'scope': {'type': 'user', 'value': 'friend@example.test'}, + } + : { + 'id': 'backoff-grant', + 'role': 'read', + 'allowedRoles': ['read', 'write'], + 'isRemovable': true, + 'emailAddress': {'address': 'friend@example.test'}, + }, + ), + 201, + ); + }); + final service = CloudCalendarSharingService( + source: source, + now: () => clock, + google: google + ? GoogleCalendarApiClient( + httpClient: transport, + baseUri: Uri.parse('https://www.googleapis.com'), + ) + : null, + microsoft: google + ? null + : MicrosoftCalendarApiClient( + httpClient: transport, + baseUri: Uri.parse('https://graph.microsoft.com/v1.0'), + responseTimeZone: 'UTC', + ), + ); + Future add() => service.add( + recipient: 'friend@example.test', + role: google ? 'reader' : 'read', + ); + await service.load(); + await expectLater(add(), throwsA(anything)); + expect(service.hasUnresolvedOutcome, isFalse); + expect( + service.retryAfter, + greaterThanOrEqualTo(const Duration(seconds: 2)), + ); + expect( + service.retryAfter, + lessThanOrEqualTo(const Duration(milliseconds: 2500)), + ); + await expectLater( + add(), + throwsA(isA()), + ); + expect(writes, 1); + clock = clock.add(const Duration(seconds: 3)); + await expectLater(add(), throwsA(anything)); + expect( + service.retryAfter, + greaterThanOrEqualTo(const Duration(seconds: 4)), + ); + expect( + service.retryAfter, + lessThanOrEqualTo(const Duration(seconds: 5)), + ); + expect(writes, 2); + clock = clock.add(const Duration(seconds: 6)); + expect((await add()).outcomeUnknown, isFalse); + expect(service.isRateLimited, isFalse); + expect(writes, 3); + }, + ); + + test( + '$provider throttled reconciliation read retains earlier uncertain write', + () async { + final google = provider == BusyProvider.google; + final source = CalendarSourceEntity( + id: 'throttle-read-$provider', + accountId: 'throttle-read-account-$provider', + provider: provider, + providerCalendarId: 'throttle-read-calendar-$provider', + summary: 'Calendar', + selected: true, + hidden: false, + readOnly: false, + isDeleted: false, + accessRole: 'owner', + primaryCalendar: !google, + ); + var writes = 0; + var reads = 0; + final transport = MockClient((request) async { + if (request.method == 'GET') { + reads++; + if (reads == 1) { + return http.Response( + jsonEncode( + google ? {'items': []} : {'value': []}, + ), + 200, + ); + } + return http.Response( + jsonEncode( + google + ? { + 'error': { + 'code': 429, + 'errors': [ + {'reason': 'rateLimitExceeded'}, + ], + }, + } + : { + 'error': {'code': 'TooManyRequests'}, + }, + ), + 429, + ); + } + writes++; + throw http.ClientException('response lost'); + }); + final service = CloudCalendarSharingService( + source: source, + google: google + ? GoogleCalendarApiClient( + httpClient: transport, + baseUri: Uri.parse('https://www.googleapis.com'), + ) + : null, + microsoft: google + ? null + : MicrosoftCalendarApiClient( + httpClient: transport, + baseUri: Uri.parse('https://graph.microsoft.com/v1.0'), + responseTimeZone: 'UTC', + ), + ); + await service.load(); + final result = await service.add( + recipient: 'friend@example.test', + role: google ? 'reader' : 'read', + ); + expect(result.outcomeUnknown, isTrue); + expect(result.refreshError, isNotNull); + expect(service.hasUnresolvedOutcome, isTrue); + expect(service.isRateLimited, isFalse); + expect((await service.load()).outcomeUnknown, isTrue); + await expectLater( + service.add( + recipient: 'friend@example.test', + role: google ? 'reader' : 'read', + ), + throwsStateError, + ); + expect(writes, 1); + }, + ); + } + + for (final provider in [BusyProvider.google, BusyProvider.microsoft]) { + for (final operation in ['add', 'change', 'revoke']) { + for (final failureMode in ['connectivity', 'authentication']) { + test( + '$provider $operation $failureMode known-unsent mutation preserves grant state', + () async { + final google = provider == BusyProvider.google; + final initialRole = google ? 'reader' : 'read'; + final changedRole = google ? 'writer' : 'write'; + final source = CalendarSourceEntity( + id: 'unsent-$provider-$operation-$failureMode', + accountId: 'unsent-account-$provider-$operation-$failureMode', + provider: provider, + providerCalendarId: + 'unsent-calendar-$provider-$operation-$failureMode', + summary: 'Calendar', + selected: true, + hidden: false, + readOnly: false, + isDeleted: false, + accessRole: 'owner', + primaryCalendar: !google, + ); + String? remoteRole = operation == 'add' ? null : initialRole; + const authFailure = OAuthException( + 'TokenUnavailable', + 'Token unavailable', + ); + var offline = false; + var providerMutations = 0; + var sharedHeaders = 0; + final guardedHttp = ConnectivityAwareHttpClient( + inner: MockClient((request) async { + if (request.method == 'GET') { + return http.Response( + jsonEncode( + google + ? { + 'items': remoteRole == null + ? [] + : [ + { + 'id': 'unsent-grant', + 'role': remoteRole, + 'scope': { + 'type': 'user', + 'value': 'friend@example.com', + }, + }, + ], + } + : { + 'value': remoteRole == null + ? [] + : [ + { + 'id': 'unsent-grant', + 'role': remoteRole, + 'allowedRoles': ['read', 'write'], + 'isRemovable': true, + 'emailAddress': { + 'address': 'friend@example.com', + }, + }, + ], + }, + ), + 200, + ); + } + providerMutations++; + remoteRole = switch (operation) { + 'add' => initialRole, + 'change' => changedRole, + _ => null, + }; + if (request.method == 'DELETE') return http.Response('', 204); + return http.Response( + jsonEncode( + google + ? { + 'id': 'unsent-grant', + 'role': remoteRole, + 'scope': { + 'type': 'user', + 'value': 'friend@example.com', + }, + } + : { + 'id': 'unsent-grant', + 'role': remoteRole, + 'allowedRoles': ['read', 'write'], + 'isRemovable': true, + 'emailAddress': {'address': 'friend@example.com'}, + }, + ), + request.method == 'POST' ? 201 : 200, + ); + }), + requireNetwork: () async { + if (offline && failureMode == 'connectivity') { + throw const NetworkUnavailableException(); + } + }, + ); + CloudCalendarSharingService create() => CloudCalendarSharingService( + source: source, + google: google + ? GoogleCalendarApiClient( + httpClient: guardedHttp, + baseUri: Uri.parse('https://www.googleapis.com'), + authorizationHeaderProvider: () async { + if (offline && failureMode == 'authentication') { + throw authFailure; + } + return 'Bearer google'; + }, + ) + : null, + microsoft: google + ? null + : MicrosoftCalendarApiClient( + httpClient: guardedHttp, + baseUri: Uri.parse('https://graph.microsoft.com/v1.0'), + responseTimeZone: 'UTC', + accountTenantId: 'tenant', + authorizationHeaderProvider: () async { + if (offline && failureMode == 'authentication') { + throw authFailure; + } + return 'Bearer microsoft'; + }, + sharedCalendarAuthorizationHeaderProvider: () async { + sharedHeaders++; + return 'Bearer shared'; + }, + ), + ); + final service = create(); + final initial = await service.load(); + expect(initial.grants.singleOrNull?.role, remoteRole); + Future mutate( + CloudCalendarSharingService target, + CloudCalendarShareSnapshot snapshot, + ) => switch (operation) { + 'add' => target.add( + recipient: 'friend@example.com', + role: initialRole, + ), + 'change' => target.change(snapshot.grants.single, changedRole), + _ => target.revoke(snapshot.grants.single), + }; + offline = true; + await expectLater( + mutate(service, initial), + throwsA( + isA() + .having( + (error) => error.kind, + 'kind', + failureMode == 'authentication' + ? RequestPreDispatchFailureKind.authentication + : RequestPreDispatchFailureKind.connectivity, + ) + .having( + (error) => error.cause, + 'cause', + failureMode == 'authentication' + ? same(authFailure) + : isNull, + ), + ), + ); + expect(providerMutations, 0); + expect(remoteRole, operation == 'add' ? null : initialRole); + expect(service.hasUnresolvedOutcome, isFalse); + offline = false; + final reopened = create(); + final baseline = await reopened.load(); + expect(baseline.outcomeUnknown, isFalse); + expect(baseline.grants.singleOrNull?.role, remoteRole); + final committed = await mutate(reopened, baseline); + expect(committed.outcomeUnknown, isFalse); + expect(providerMutations, 1); + expect(committed.grants.singleOrNull?.role, remoteRole); + expect(sharedHeaders, 0); + }, + ); + } + } + } + + test('acknowledged Google grant survives failed follow-up refresh', () async { + var listCalls = 0; + var postCalls = 0; + final service = CloudCalendarSharingService( + source: googleSource, + google: GoogleCalendarApiClient( + httpClient: MockClient((request) async { + if (request.method == 'GET') { + listCalls++; + return listCalls == 1 + ? http.Response(jsonEncode({'items': []}), 200) + : http.Response('temporary failure', 503); + } + if (request.method == 'POST') { + postCalls++; + expect(jsonDecode(request.body), { + 'role': 'reader', + 'scope': {'type': 'user', 'value': 'friend@example.com'}, + }); + return http.Response( + jsonEncode({ + 'id': 'user:friend@example.com', + 'role': 'reader', + 'scope': {'type': 'user', 'value': 'friend@example.com'}, + }), + 200, + ); + } + fail('Unexpected request: ${request.method} ${request.url}'); + }), + baseUri: Uri.parse('https://www.googleapis.com'), + ), + ); + expect((await service.load()).grants, isEmpty); + final result = await service.add( + recipient: 'friend@example.com', + role: 'reader', + ); + expect(result.refreshError, isNotNull); + expect(result.grants.single.recipient, 'friend@example.com'); + expect(postCalls, 1); + }); + + test( + 'lost Google add response reconciles committed grant without retry', + () async { + var committed = false; + var posts = 0; + var gets = 0; + final service = CloudCalendarSharingService( + source: googleSource, + google: GoogleCalendarApiClient( + httpClient: MockClient((request) async { + if (request.method == 'GET') { + gets++; + return http.Response( + jsonEncode({ + 'items': committed + ? [ + { + 'id': 'user:friend@example.com', + 'role': 'reader', + 'scope': { + 'type': 'user', + 'value': 'friend@example.com', + }, + }, + ] + : [], + }), + 200, + ); + } + if (request.method == 'POST') { + posts++; + committed = true; + throw http.ClientException('response lost'); + } + fail('Unexpected request'); + }), + baseUri: Uri.parse('https://www.googleapis.com'), + ), + ); + await service.load(); + final result = await service.add( + recipient: 'friend@example.com', + role: 'reader', + ); + expect(result.grants.single.recipient, 'friend@example.com'); + expect(posts, 1); + expect(gets, 2); + }, + ); + + for (final provider in [BusyProvider.google, BusyProvider.microsoft]) { + for (final kind in ['add', 'change', 'revoke']) { + test( + '$provider lost $kind response reconciles the targeted grant', + () async { + final google = provider == BusyProvider.google; + final beforeRole = google ? 'reader' : 'read'; + final afterRole = google ? 'writer' : 'write'; + final source = CalendarSourceEntity( + id: '$provider-$kind', + accountId: 'account-$provider-$kind', + provider: provider, + providerCalendarId: 'calendar-$provider-$kind', + summary: 'Calendar', + selected: true, + hidden: false, + readOnly: false, + isDeleted: false, + accessRole: 'owner', + primaryCalendar: !google, + ); + String? remoteRole = kind == 'add' ? null : beforeRole; + var mutations = 0; + var reads = 0; + Map googleGrant() => { + 'id': 'grant-1', + 'role': remoteRole, + 'scope': {'type': 'user', 'value': 'friend@example.com'}, + }; + Map microsoftGrant() => { + 'id': 'grant-1', + 'role': remoteRole, + 'allowedRoles': ['read', 'write'], + 'isRemovable': true, + 'emailAddress': {'address': 'friend@example.com'}, + }; + final httpClient = MockClient((request) async { + if (request.method == 'GET') { + reads++; + return http.Response( + jsonEncode( + google + ? { + 'items': remoteRole == null + ? [] + : [googleGrant()], + } + : { + 'value': remoteRole == null + ? [] + : [microsoftGrant()], + }, + ), + 200, + ); + } + mutations++; + remoteRole = kind == 'revoke' + ? null + : kind == 'change' + ? afterRole + : beforeRole; + throw http.ClientException('committed but response lost'); + }); + final service = CloudCalendarSharingService( + source: source, + google: google + ? GoogleCalendarApiClient( + httpClient: httpClient, + baseUri: Uri.parse('https://www.googleapis.com'), + ) + : null, + microsoft: google + ? null + : MicrosoftCalendarApiClient( + httpClient: httpClient, + baseUri: Uri.parse('https://graph.microsoft.com/v1.0'), + responseTimeZone: 'UTC', + accountTenantId: 'tenant', + ), + ); + final baseline = await service.load(); + final result = switch (kind) { + 'add' => await service.add( + recipient: 'friend@example.com', + role: beforeRole, + ), + 'change' => await service.change(baseline.grants.single, afterRole), + _ => await service.revoke(baseline.grants.single), + }; + expect(result.outcomeUnknown, isFalse); + expect(result.grants.singleOrNull?.role, remoteRole); + expect(mutations, 1); + expect(reads, 2); + expect(service.hasUnresolvedOutcome, isFalse); + }, + ); + } + } + + test( + 'failed and malformed reconciliation stays gated across reopened service', + () async { + final source = const CalendarSourceEntity( + id: 'uncertain-source', + accountId: 'uncertain-account', + provider: BusyProvider.google, + providerCalendarId: 'uncertain-calendar', + summary: 'Calendar', + selected: true, + hidden: false, + readOnly: false, + isDeleted: false, + accessRole: 'owner', + ); + var committed = false; + var malformed = false; + var failed = false; + var posts = 0; + final client = GoogleCalendarApiClient( + httpClient: MockClient((request) async { + if (request.method == 'GET') { + if (failed) return http.Response('unavailable', 503); + if (malformed) return http.Response('{}', 200); + return http.Response( + jsonEncode({ + 'items': committed + ? [ + { + 'id': 'grant-uncertain', + 'role': 'reader', + 'scope': { + 'type': 'user', + 'value': 'friend@example.com', + }, + }, + ] + : [], + }), + 200, + ); + } + posts++; + committed = true; + failed = true; + throw http.ClientException('response lost'); + }), + baseUri: Uri.parse('https://www.googleapis.com'), + ); + CloudCalendarSharingService create() => + CloudCalendarSharingService(source: source, google: client); + final service = create(); + await service.load(); + final uncertain = await service.add( + recipient: 'friend@example.com', + role: 'reader', + ); + expect(uncertain.outcomeUnknown, isTrue); + expect(uncertain.refreshError, isNotNull); + await expectLater( + service.add(recipient: 'other@example.com', role: 'reader'), + throwsStateError, + ); + final reopened = create(); + expect((await reopened.load()).outcomeUnknown, isTrue); + failed = false; + malformed = true; + expect((await reopened.load()).outcomeUnknown, isTrue); + expect(posts, 1); + malformed = false; + final recovered = await reopened.load(); + expect(recovered.outcomeUnknown, isFalse); + expect(recovered.grants.single.id, 'grant-uncertain'); + expect(reopened.hasUnresolvedOutcome, isFalse); + expect(posts, 1); + }, + ); + + test( + 'partial ACL listing and source switch cannot release uncertain intent', + () async { + const sourceA = CalendarSourceEntity( + id: 'partial-a', + accountId: 'partial-account', + provider: BusyProvider.google, + providerCalendarId: 'partial-calendar-a', + summary: 'A', + selected: true, + hidden: false, + readOnly: false, + isDeleted: false, + accessRole: 'owner', + ); + const sourceB = CalendarSourceEntity( + id: 'partial-b', + accountId: 'partial-account', + provider: BusyProvider.google, + providerCalendarId: 'partial-calendar-b', + summary: 'B', + selected: true, + hidden: false, + readOnly: false, + isDeleted: false, + accessRole: 'owner', + ); + var committed = false; + var secondPageFails = true; + var posts = 0; + final client = GoogleCalendarApiClient( + httpClient: MockClient((request) async { + if (request.method == 'POST') { + posts++; + committed = true; + throw http.ClientException('response lost'); + } + if (request.url.path.contains('partial-calendar-b')) { + return http.Response(jsonEncode({'items': []}), 200); + } + if (!committed) { + return http.Response(jsonEncode({'items': []}), 200); + } + if (request.url.queryParameters['pageToken'] == 'second') { + if (secondPageFails) return http.Response('later page failed', 503); + return http.Response( + jsonEncode({ + 'items': [ + { + 'id': 'partial-grant', + 'role': 'reader', + 'scope': {'type': 'user', 'value': 'friend@example.com'}, + }, + ], + }), + 200, + ); + } + return http.Response( + jsonEncode({'items': [], 'nextPageToken': 'second'}), + 200, + ); + }), + baseUri: Uri.parse('https://www.googleapis.com'), + ); + CloudCalendarSharingService forSource(CalendarSourceEntity source) => + CloudCalendarSharingService(source: source, google: client); + final first = forSource(sourceA); + await first.load(); + final uncertain = await first.add( + recipient: 'friend@example.com', + role: 'reader', + ); + expect(uncertain.outcomeUnknown, isTrue); + expect(uncertain.refreshError, isNotNull); + final other = forSource(sourceB); + expect((await other.load()).outcomeUnknown, isFalse); + final reopened = forSource(sourceA); + expect((await reopened.load()).outcomeUnknown, isTrue); + await expectLater( + reopened.add(recipient: 'friend@example.com', role: 'reader'), + throwsStateError, + ); + expect(posts, 1); + secondPageFails = false; + expect((await reopened.load()).outcomeUnknown, isFalse); + expect(posts, 1); + }, + ); + + test( + 'unexpected remote sharing role remains unresolved, not rewritten', + () async { + const source = CalendarSourceEntity( + id: 'unexpected-source', + accountId: 'unexpected-account', + provider: BusyProvider.google, + providerCalendarId: 'unexpected-calendar', + summary: 'Calendar', + selected: true, + hidden: false, + readOnly: false, + isDeleted: false, + accessRole: 'owner', + ); + var role = 'reader'; + var patches = 0; + final client = GoogleCalendarApiClient( + httpClient: MockClient((request) async { + if (request.method == 'GET') { + return http.Response( + jsonEncode({ + 'items': [ + { + 'id': 'grant', + 'role': role, + 'scope': {'type': 'user', 'value': 'friend@example.com'}, + }, + ], + }), + 200, + ); + } + patches++; + role = 'freeBusyReader'; + throw http.ClientException('response lost'); + }), + baseUri: Uri.parse('https://www.googleapis.com'), + ); + final service = CloudCalendarSharingService( + source: source, + google: client, + ); + final grant = (await service.load()).grants.single; + final result = await service.change(grant, 'writer'); + expect(result.outcomeUnknown, isTrue); + expect(result.grants.single.role, 'freeBusyReader'); + await expectLater(service.revoke(grant), throwsStateError); + expect(patches, 1); + role = 'writer'; + expect((await service.load()).outcomeUnknown, isFalse); + }, + ); + + test('definitive sharing rejection restores the mutation gate', () async { + const source = CalendarSourceEntity( + id: 'rejected-source', + accountId: 'rejected-account', + provider: BusyProvider.google, + providerCalendarId: 'rejected-calendar', + summary: 'Calendar', + selected: true, + hidden: false, + readOnly: false, + isDeleted: false, + accessRole: 'owner', + ); + var posts = 0; + final service = CloudCalendarSharingService( + source: source, + google: GoogleCalendarApiClient( + httpClient: MockClient((request) async { + if (request.method == 'GET') { + return http.Response(jsonEncode({'items': []}), 200); + } + posts++; + return http.Response( + jsonEncode({ + 'error': { + 'code': 403, + 'message': 'Denied', + 'errors': [ + {'domain': 'global', 'reason': 'forbidden'}, + ], + }, + }), + 403, + ); + }), + baseUri: Uri.parse('https://www.googleapis.com'), + ), + ); + await service.load(); + await expectLater( + service.add(recipient: 'friend@example.com', role: 'reader'), + throwsA(isA()), + ); + expect(service.hasUnresolvedOutcome, isFalse); + expect(service.isRateLimited, isFalse); + expect(posts, 1); + }); + + test('Microsoft immutable grants cannot be changed or revoked', () async { + final service = CloudCalendarSharingService( + source: const CalendarSourceEntity( + id: 'primary', + accountId: 'account', + provider: BusyProvider.microsoft, + providerCalendarId: 'primary-id', + summary: 'Calendar', + selected: true, + hidden: false, + readOnly: false, + isDeleted: false, + primaryCalendar: true, + ), + microsoft: MicrosoftCalendarApiClient( + httpClient: MockClient( + (request) async => http.Response( + jsonEncode({ + 'value': [ + { + 'id': 'owner', + 'role': 'owner', + 'allowedRoles': ['owner'], + 'isRemovable': false, + 'emailAddress': {'address': 'owner@example.com'}, + }, + ], + }), + 200, + ), + ), + baseUri: Uri.parse('https://graph.microsoft.com/v1.0'), + responseTimeZone: 'UTC', + accountTenantId: 'tenant', + ), + ); + final grant = (await service.load()).grants.single; + expect(grant.canChange, isFalse); + expect(grant.canRevoke, isFalse); + await expectLater(service.change(grant, 'read'), throwsArgumentError); + await expectLater(service.revoke(grant), throwsArgumentError); + }); + + testWidgets('native sharing content exposes add and refresh-failure retry', ( + tester, + ) async { + var gets = 0; + var posts = 0; + final client = GoogleCalendarApiClient( + httpClient: MockClient((request) async { + if (request.method == 'GET') { + gets++; + if (gets == 2) return http.Response('try again', 503); + return http.Response( + jsonEncode({ + 'items': gets == 1 + ? [] + : [ + { + 'id': 'grant', + 'role': 'reader', + 'scope': { + 'type': 'user', + 'value': 'friend@example.com', + }, + }, + ], + }), + 200, + ); + } + if (request.method == 'POST') { + posts++; + return http.Response( + jsonEncode({ + 'id': 'grant', + 'role': 'reader', + 'scope': {'type': 'user', 'value': 'friend@example.com'}, + }), + 200, + ); + } + fail('Unexpected request'); + }), + baseUri: Uri.parse('https://www.googleapis.com'), + ); + await tester.pumpWidget( + ProviderScope( + child: localizedTestApp( + child: Scaffold( + body: CloudCalendarSharingContent( + sources: const [googleSource], + serviceFactory: (source) => + CloudCalendarSharingService(source: source, google: client), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + await tester.enterText( + find.byKey(const Key('calendar-sharing-recipient')), + 'friend@example.com', + ); + await tester.tap(find.byKey(const Key('calendar-sharing-new-role-source'))); + await tester.pumpAndSettle(); + await tester.tap(find.text('Read all details').last); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('calendar-sharing-add'))); + await tester.pumpAndSettle(); + expect(posts, 1); + expect(find.text('friend@example.com'), findsWidgets); + expect( + tester + .widget(find.byKey(const Key('calendar-sharing-add'))) + .onPressed, + isNull, + ); + await tester.tap(find.text('Retry')); + await tester.pumpAndSettle(); + expect(gets, 3); + expect( + tester + .widget(find.byKey(const Key('calendar-sharing-add'))) + .onPressed, + isNotNull, + ); + }); + + for (final android in [false, true]) { + testWidgets( + '${android ? 'Android' : 'Linux'} throttle keeps grants visible and gates only until explicit retry', + (tester) async { + final source = CalendarSourceEntity( + id: 'native-throttle-$android', + accountId: 'native-throttle-account-$android', + provider: BusyProvider.google, + providerCalendarId: 'native-throttle-calendar-$android', + summary: 'Calendar', + selected: true, + hidden: false, + readOnly: false, + isDeleted: false, + accessRole: 'owner', + ); + var clock = DateTime.now(); + var posts = 0; + var committed = false; + final client = GoogleCalendarApiClient( + httpClient: MockClient((request) async { + if (request.method == 'GET') { + return http.Response( + jsonEncode({ + 'items': [ + { + 'id': 'existing-grant', + 'role': 'reader', + 'scope': { + 'type': 'user', + 'value': 'existing@example.test', + }, + }, + if (committed) + { + 'id': 'new-grant', + 'role': 'freeBusyReader', + 'scope': { + 'type': 'user', + 'value': 'friend@example.test', + }, + }, + ], + }), + 200, + ); + } + posts++; + if (posts == 1) { + return http.Response( + jsonEncode({ + 'error': { + 'code': 403, + 'message': 'Quota', + 'errors': [ + {'reason': 'userRateLimitExceeded'}, + ], + }, + }), + 403, + headers: {'retry-after': '5'}, + ); + } + committed = true; + return http.Response( + jsonEncode({ + 'id': 'new-grant', + 'role': 'freeBusyReader', + 'scope': {'type': 'user', 'value': 'friend@example.test'}, + }), + 200, + ); + }), + baseUri: Uri.parse('https://www.googleapis.com'), + ); + Widget content() => android + ? CloudCalendarSharingContent( + sources: [source], + serviceFactory: (s) => CloudCalendarSharingService( + source: s, + google: client, + now: () => clock, + ), + ) + : linux.CloudCalendarSharingContent( + sources: [source], + serviceFactory: (s) => CloudCalendarSharingService( + source: s, + google: client, + now: () => clock, + ), + ); + bool addEnabled() => switch (tester.widget( + find.byKey(const Key('calendar-sharing-add')), + )) { + TextButton button => button.onPressed != null, + FilledButton button => button.onPressed != null, + _ => throw StateError('Unexpected Add control.'), + }; + bool roleEnabled() { + final grant = find.byKey( + const Key('calendar-sharing-grant-existing-grant'), + ); + return android + ? tester + .widget>( + find.descendant( + of: grant, + matching: find.byType(PopupMenuButton), + ), + ) + .enabled + : tester + .widget>( + find.descendant( + of: grant, + matching: find.byType(BusyMaxMenuButton), + ), + ) + .enabled; + } + + bool revokeEnabled() { + final grant = find.byKey( + const Key('calendar-sharing-grant-existing-grant'), + ); + return android + ? tester + .widget( + find.ancestor( + of: find.descendant( + of: grant, + matching: find.byIcon( + Icons.person_remove_outlined, + ), + ), + matching: find.byType(IconButton), + ), + ) + .onPressed != + null + : tester + .widget( + find.descendant( + of: grant, + matching: find.byType(FilledButton), + ), + ) + .onPressed != + null; + } + + await tester.pumpWidget( + ProviderScope( + child: localizedTestApp(child: Scaffold(body: content())), + ), + ); + await tester.pumpAndSettle(); + await tester.enterText( + find.byKey(const Key('calendar-sharing-recipient')), + 'friend@example.test', + ); + await tester.tap(find.byKey(const Key('calendar-sharing-add'))); + for (var i = 0; i < 4; i++) { + await tester.pump(); + } + expect(posts, 1); + expect( + find.text( + 'Sharing is temporarily rate-limited. Try again when the wait ends.', + ), + findsOneWidget, + ); + expect( + find.byKey(const Key('calendar-sharing-grant-existing-grant')), + findsOneWidget, + ); + expect(addEnabled(), isFalse); + expect(roleEnabled(), isFalse); + expect(revokeEnabled(), isFalse); + await tester.tap(find.text('Retry')); + for (var i = 0; i < 4; i++) { + await tester.pump(); + } + expect(posts, 1); + expect(addEnabled(), isFalse); + await tester.pumpWidget(const SizedBox()); + await tester.pumpWidget( + ProviderScope( + child: localizedTestApp(child: Scaffold(body: content())), + ), + ); + await tester.pumpAndSettle(); + expect(addEnabled(), isFalse); + clock = clock.add(const Duration(seconds: 5)); + await tester.pump(const Duration(seconds: 5)); + await tester.pump(); + expect(addEnabled(), isTrue); + expect(roleEnabled(), isTrue); + expect(revokeEnabled(), isTrue); + expect(posts, 1); + await tester.enterText( + find.byKey(const Key('calendar-sharing-recipient')), + 'friend@example.test', + ); + await tester.tap(find.byKey(const Key('calendar-sharing-add'))); + await tester.pumpAndSettle(); + expect(posts, 2); + expect( + find.byKey(const Key('calendar-sharing-grant-new-grant')), + findsOneWidget, + ); + }, + ); + + testWidgets( + '${android ? 'Android' : 'Linux'} known-unsent sharing add restores mutation controls', + (tester) async { + final source = CalendarSourceEntity( + id: 'native-unsent-$android', + accountId: 'native-unsent-account-$android', + provider: BusyProvider.google, + providerCalendarId: 'native-unsent-calendar-$android', + summary: 'Calendar', + selected: true, + hidden: false, + readOnly: false, + isDeleted: false, + accessRole: 'owner', + ); + final preflight = Completer(); + var blockDispatch = false; + var providerMutations = 0; + final client = GoogleCalendarApiClient( + httpClient: ConnectivityAwareHttpClient( + inner: MockClient((request) async { + if (request.method == 'GET') { + return http.Response( + jsonEncode({ + 'items': [ + { + 'id': 'existing-grant', + 'role': 'reader', + 'scope': { + 'type': 'user', + 'value': 'existing@example.com', + }, + }, + if (providerMutations > 0) + { + 'id': 'new-grant', + 'role': 'freeBusyReader', + 'scope': { + 'type': 'user', + 'value': 'friend@example.com', + }, + }, + ], + }), + 200, + ); + } + providerMutations++; + return http.Response( + jsonEncode({ + 'id': 'new-grant', + 'role': 'freeBusyReader', + 'scope': {'type': 'user', 'value': 'friend@example.com'}, + }), + 200, + ); + }), + requireNetwork: () async { + if (blockDispatch) { + await preflight.future; + throw const NetworkUnavailableException(); + } + }, + ), + baseUri: Uri.parse('https://www.googleapis.com'), + ); + Widget content() => android + ? CloudCalendarSharingContent( + sources: [source], + serviceFactory: (s) => + CloudCalendarSharingService(source: s, google: client), + ) + : linux.CloudCalendarSharingContent( + sources: [source], + serviceFactory: (s) => + CloudCalendarSharingService(source: s, google: client), + ); + VoidCallback? addAction() => switch (tester.widget( + find.byKey(const Key('calendar-sharing-add')), + )) { + TextButton button => button.onPressed, + FilledButton button => button.onPressed, + _ => throw StateError('Unexpected native Add control.'), + }; + bool roleEnabled() { + final grant = find.byKey( + const Key('calendar-sharing-grant-existing-grant'), + ); + return android + ? tester + .widget>( + find.descendant( + of: grant, + matching: find.byType(PopupMenuButton), + ), + ) + .enabled + : tester + .widget>( + find.descendant( + of: grant, + matching: find.byType(BusyMaxMenuButton), + ), + ) + .enabled; + } + + bool revokeEnabled() { + final grant = find.byKey( + const Key('calendar-sharing-grant-existing-grant'), + ); + return android + ? tester + .widget( + find.ancestor( + of: find.descendant( + of: grant, + matching: find.byIcon( + Icons.person_remove_outlined, + ), + ), + matching: find.byType(IconButton), + ), + ) + .onPressed != + null + : tester + .widget( + find.descendant( + of: grant, + matching: find.byType(FilledButton), + ), + ) + .onPressed != + null; + } + + await tester.pumpWidget( + ProviderScope( + child: localizedTestApp(child: Scaffold(body: content())), + ), + ); + await tester.pumpAndSettle(); + await tester.enterText( + find.byKey(const Key('calendar-sharing-recipient')), + 'friend@example.com', + ); + blockDispatch = true; + await tester.tap(find.byKey(const Key('calendar-sharing-add'))); + await tester.pump(); + expect(addAction(), isNull); + expect(providerMutations, 0); + preflight.complete(); + for (var i = 0; i < 4; i++) { + await tester.pump(); + } + expect( + find.textContaining('NetworkUnavailableException'), + findsOneWidget, + ); + expect(addAction(), isNotNull); + expect(roleEnabled(), isTrue); + expect(revokeEnabled(), isTrue); + expect(providerMutations, 0); + await tester.pumpWidget(const SizedBox()); + blockDispatch = false; + await tester.pumpWidget( + ProviderScope( + child: localizedTestApp(child: Scaffold(body: content())), + ), + ); + await tester.pumpAndSettle(); + expect(addAction(), isNotNull); + await tester.enterText( + find.byKey(const Key('calendar-sharing-recipient')), + 'friend@example.com', + ); + await tester.tap(find.byKey(const Key('calendar-sharing-add'))); + await tester.pumpAndSettle(); + expect(providerMutations, 1); + }, + ); + + testWidgets( + '${android ? 'Android' : 'Linux'} sharing remains read-only after lost response and recovers on Refresh', + (tester) async { + final source = CalendarSourceEntity( + id: 'native-uncertain-$android', + accountId: 'native-account-$android', + provider: BusyProvider.google, + providerCalendarId: 'native-calendar-$android', + summary: 'Calendar', + selected: true, + hidden: false, + readOnly: false, + isDeleted: false, + accessRole: 'owner', + ); + var posts = 0; + var failReads = true; + final heldRead = Completer(); + final client = GoogleCalendarApiClient( + httpClient: MockClient((request) async { + if (request.method == 'GET') { + if (posts == 0) { + return http.Response(jsonEncode({'items': []}), 200); + } + if (failReads) return heldRead.future; + return http.Response( + jsonEncode({ + 'items': [ + { + 'id': 'native-grant', + 'role': 'freeBusyReader', + 'scope': {'type': 'user', 'value': 'friend@example.com'}, + }, + ], + }), + 200, + ); + } + posts++; + throw http.ClientException('response lost'); + }), + baseUri: Uri.parse('https://www.googleapis.com'), + ); + Widget content() => android + ? CloudCalendarSharingContent( + sources: [source], + serviceFactory: (s) => + CloudCalendarSharingService(source: s, google: client), + ) + : linux.CloudCalendarSharingContent( + sources: [source], + serviceFactory: (s) => + CloudCalendarSharingService(source: s, google: client), + ); + VoidCallback? addAction() => switch (tester.widget( + find.byKey(const Key('calendar-sharing-add')), + )) { + TextButton button => button.onPressed, + FilledButton button => button.onPressed, + _ => throw StateError('Unexpected native Add control.'), + }; + await tester.pumpWidget( + ProviderScope( + child: localizedTestApp(child: Scaffold(body: content())), + ), + ); + await tester.pumpAndSettle(); + await tester.enterText( + find.byKey(const Key('calendar-sharing-recipient')), + 'friend@example.com', + ); + await tester.tap(find.byKey(const Key('calendar-sharing-add'))); + for (var i = 0; i < 4; i++) { + await tester.pump(); + } + expect(posts, 1); + expect(addAction(), isNull); + heldRead.complete(http.Response('unavailable', 503)); + for (var i = 0; i < 4; i++) { + await tester.pump(); + } + expect(addAction(), isNull); + expect(find.text('Retry'), findsOneWidget); + // Recreating the native content must not discard the account/calendar gate. + await tester.pumpWidget(const SizedBox()); + await tester.pumpWidget( + ProviderScope( + child: localizedTestApp(child: Scaffold(body: content())), + ), + ); + for (var i = 0; i < 5; i++) { + await tester.pump(); + } + expect(addAction(), isNull); + failReads = false; + await tester.tap(find.text('Retry')); + await tester.pumpAndSettle(); + expect(posts, 1); + expect(addAction(), isNotNull); + }, + ); + } + + testWidgets( + 'Linux sharing content adds an explicit grant through native controls', + (tester) async { + final requests = []; + final client = GoogleCalendarApiClient( + httpClient: MockClient((request) async { + requests.add(request); + if (request.method == 'GET') { + return http.Response(jsonEncode({'items': []}), 200); + } + if (request.method == 'POST') { + return http.Response( + jsonEncode({ + 'id': 'reader', + 'role': 'reader', + 'scope': {'type': 'user', 'value': 'friend@example.com'}, + }), + 200, + ); + } + fail('Unexpected request'); + }), + baseUri: Uri.parse('https://www.googleapis.com'), + ); + await tester.pumpWidget( + ProviderScope( + child: localizedTestApp( + child: Scaffold( + body: linux.CloudCalendarSharingContent( + sources: const [googleSource], + serviceFactory: (source) => + CloudCalendarSharingService(source: source, google: client), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + await tester.enterText( + find.byKey(const Key('calendar-sharing-recipient')), + 'friend@example.com', + ); + tester + .widget>( + find.byKey(const Key('calendar-sharing-new-role-source')), + ) + .onSelected('reader'); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('calendar-sharing-add'))); + await tester.pumpAndSettle(); + final post = requests.singleWhere((request) => request.method == 'POST'); + expect(jsonDecode(post.body), { + 'role': 'reader', + 'scope': {'type': 'user', 'value': 'friend@example.com'}, + }); + }, + ); +} diff --git a/test/features/calendar/domain/google_status_event_test.dart b/test/features/calendar/domain/google_status_event_test.dart new file mode 100644 index 00000000..b487d935 --- /dev/null +++ b/test/features/calendar/domain/google_status_event_test.dart @@ -0,0 +1,104 @@ +import 'package:busymax/src/features/calendar/domain/google_status_event.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + final start = DateTime.utc(2026, 6, 8, 9); + final end = DateTime.utc(2026, 6, 8, 10); + + void validate( + String type, { + bool primary = true, + bool allDay = false, + DateTime? finish, + String? original, + String? visibility, + String? transparency, + Map? properties, + }) => validateGoogleStatusEvent( + primaryCalendar: primary, + eventType: type, + originalEventType: original, + allDay: allDay, + start: start, + end: finish ?? end, + visibility: + visibility ?? (type == 'workingLocation' ? 'public' : 'default'), + transparency: + transparency ?? (type == 'workingLocation' ? 'transparent' : 'opaque'), + properties: properties ?? defaultGoogleStatusProperties(type), + ); + + test( + 'all three documented status types validate on the primary calendar', + () { + for (final type in googleStatusEventTypes) { + validate(type); + expect(defaultGoogleStatusProperties(type), isNotEmpty); + } + }, + ); + + test('secondary calendars and in-place type conversion are rejected', () { + expect(() => validate('focusTime', primary: false), throwsUnsupportedError); + expect( + () => validate('focusTime', original: 'default'), + throwsUnsupportedError, + ); + expect(() => validate('fromGmail'), throwsUnsupportedError); + }); + + test( + 'existing Gmail-derived type remains valid without allowing creation', + () { + validate('fromGmail', original: 'fromGmail'); + expect(() => validate('fromGmail'), throwsUnsupportedError); + expect( + () => validate('default', original: 'fromGmail'), + throwsUnsupportedError, + ); + }, + ); + + test( + 'timing, transparency, visibility, and working-location form are enforced', + () { + expect(() => validate('focusTime', allDay: true), throwsArgumentError); + expect( + () => validate('outOfOffice', transparency: 'transparent'), + throwsArgumentError, + ); + expect( + () => validate('workingLocation', visibility: 'private'), + throwsArgumentError, + ); + expect( + () => validate( + 'workingLocation', + allDay: true, + finish: DateTime.utc(2026, 6, 10), + ), + throwsArgumentError, + ); + expect( + () => validate('workingLocation', properties: const {'type': 'other'}), + throwsArgumentError, + ); + }, + ); + + test( + 'unknown provider fields remain in status properties during extraction', + () { + expect( + googleStatusPropertiesFromRaw('workingLocation', { + 'workingLocationProperties': { + 'type': 'officeLocation', + 'officeLocation': {'buildingId': 'building', 'label': 'HQ'}, + 'providerOwned': 'keep', + }, + })['providerOwned'], + 'keep', + ); + }, + ); +} diff --git a/test/features/calendar/presentation/event_editor_test.dart b/test/features/calendar/presentation/event_editor_test.dart index 65721a67..7ef475d2 100644 --- a/test/features/calendar/presentation/event_editor_test.dart +++ b/test/features/calendar/presentation/event_editor_test.dart @@ -1,9 +1,14 @@ import 'dart:io'; +import 'dart:convert'; import 'package:busymax/src/features/accounts/data/accounts_repository.dart'; +import 'package:busymax/src/calendar_providers/calendar_sync_dto.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.dart'; import 'package:busymax/src/features/calendar/presentation/event_editor_draft.dart'; +import 'package:busymax/src/features/calendar/presentation/linux_cloud_availability_dialog.dart'; +import 'package:busymax/src/features/calendar/presentation/event_description_editor.dart'; import 'package:busymax/src/features/maps/domain/geographic_point.dart'; import 'package:busymax/src/features/maps/domain/location_result.dart'; import 'package:busymax/src/features/recurrence/domain/event_recurrence_codec.dart'; @@ -13,12 +18,21 @@ import 'package:busymax/src/features/tasks/presentation/desktop_date_time_fields import 'package:busymax/src/app/busymax_design.dart'; import 'package:busymax/src/app/busymax_window_close.dart'; import 'package:busymax/src/app/busymax_yaru_theme.dart'; +import 'package:busymax/src/app/app_bootstrap.dart'; +import 'package:busymax/src/google_calendar/google_calendar_api_client.dart'; +import 'package:busymax/src/google_calendar/google_calendar_models.dart'; +import 'package:busymax/src/microsoft_calendar/microsoft_calendar_models.dart'; import 'package:busymax/src/platform/native_dialog_service.dart'; import 'package:busymax/src/platform/native_menu_service.dart'; import 'package:busymax/src/providers/busy_provider.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:drift/drift.dart' show Value; +import 'package:drift/native.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; import 'package:yaru/yaru.dart'; import '../../../test_localized_app.dart'; @@ -27,6 +41,223 @@ const _nativeDialogChannel = MethodChannel(nativeDialogChannelName); const _nativeMenuChannel = MethodChannel(nativeMenuChannelName); void main() { + testWidgets('Google all-day Linux availability uses calendar timezone', ( + tester, + ) async { + final database = AppDatabase(NativeDatabase.memory()); + addTearDown(database.close); + await database + .into(database.accounts) + .insert( + AccountsCompanion.insert( + id: 'account', + provider: 'google', + authority: 'https://accounts.google.com', + providerAccountId: 'me@example.test', + credentialKind: 'oauth', + authState: const Value('signed_in'), + createdAtUtc: '2026-07-01T00:00:00Z', + updatedAtUtc: '2026-07-01T00:00:00Z', + ), + ); + final repository = CalendarRepository(database: database); + await repository.upsertSource( + accountId: 'account', + source: const CalendarSourceDto( + provider: BusyProvider.google, + providerCalendarId: 'calendar', + summary: 'Tokyo', + timeZone: 'Asia/Tokyo', + accessRole: 'owner', + ), + ); + await repository.upsertEvent( + accountId: 'account', + event: const CalendarEventDto( + provider: BusyProvider.google, + providerCalendarId: 'calendar', + providerEventId: 'all-day', + title: 'All day', + allDay: true, + startDate: '2026-07-15', + endDate: '2026-07-16', + attendeesJson: [ + {'email': 'guest@example.test'}, + ], + ), + ); + final detail = await repository.loadEventDetail( + (await database.select(database.calendarEvents).getSingle()).id, + ); + final draft = EventEditorDraft.fromEventDetail(detail!); + final source = CalendarSourceEntity.fromRow( + await database.select(database.calendarSources).getSingle(), + ); + expect(draft.allDay, isTrue); + expect(draft.startTimeZone, isNull); + expect(source.timeZone, 'Asia/Tokyo'); + Map? sent; + final client = GoogleCalendarApiClient( + httpClient: MockClient((request) async { + sent = (jsonDecode(request.body) as Map).cast(); + return http.Response( + jsonEncode({ + 'calendars': { + 'guest@example.test': {'busy': []}, + }, + }), + 200, + ); + }), + baseUri: Uri.parse('https://www.googleapis.com'), + authorizationHeaderProvider: () async => 'Bearer token', + ); + await tester.pumpWidget( + ProviderScope( + overrides: [ + calendarRemoteApiClientForAccountProvider( + 'account', + ).overrideWithValue(client), + ], + child: localizedTestApp( + theme: BusyMaxYaruTheme.build( + brightness: Brightness.light, + accentColor: const Color(0xFF3584E4), + ), + child: Scaffold( + body: Builder( + builder: (context) => TextButton( + onPressed: () => showLinuxCloudAvailabilityDialog( + context, + draft: draft, + calendarTimeZone: source.timeZone, + ), + child: const Text('Open availability'), + ), + ), + ), + ), + ), + ); + await tester.tap(find.text('Open availability')); + await tester.pumpAndSettle(); + expect(sent?['timeMin'], '2026-07-14T15:00:00.000Z'); + expect(sent?['timeMax'], '2026-07-15T15:00:00.000Z'); + }); + testWidgets('C Linux Google availability sends resolved UTC interval', ( + tester, + ) async { + Map? sent; + final client = GoogleCalendarApiClient( + httpClient: MockClient((request) async { + sent = (jsonDecode(request.body) as Map).cast(); + return http.Response( + jsonEncode({ + 'calendars': { + 'guest@example.test': {'busy': []}, + }, + }), + 200, + ); + }), + baseUri: Uri.parse('https://www.googleapis.com'), + authorizationHeaderProvider: () async => 'Bearer token', + ); + final draft = + EventEditorDraft.newEvent( + accountId: 'account', + sourceId: 'calendar', + providerCalendarId: 'calendar', + start: DateTime(2026, 7, 15, 9), + end: DateTime(2026, 7, 15, 10), + ).copyWith( + startTimeZone: 'America/Los_Angeles', + endTimeZone: 'America/Los_Angeles', + attendees: const [EventAttendeeDraft(email: 'guest@example.test')], + ); + await tester.pumpWidget( + ProviderScope( + overrides: [ + calendarRemoteApiClientForAccountProvider( + 'account', + ).overrideWithValue(client), + ], + child: localizedTestApp( + theme: BusyMaxYaruTheme.build( + brightness: Brightness.light, + accentColor: const Color(0xFF3584E4), + ), + child: Scaffold( + body: Builder( + builder: (context) => TextButton( + onPressed: () => + showLinuxCloudAvailabilityDialog(context, draft: draft), + child: const Text('Open availability'), + ), + ), + ), + ), + ), + ); + await tester.tap(find.text('Open availability')); + await tester.pumpAndSettle(); + expect(sent?['timeMin'], '2026-07-15T16:00:00.000Z'); + expect(sent?['timeMax'], '2026-07-15T17:00:00.000Z'); + }); + test('C cloud availability resolves civil times in their provider zones', () { + EventEditorDraft draft(DateTime start, DateTime end, String endZone) => + EventEditorDraft.newEvent( + accountId: 'account', + sourceId: 'calendar', + providerCalendarId: 'calendar', + start: start, + end: end, + ).copyWith(startTimeZone: 'America/Los_Angeles', endTimeZone: endZone); + final summer = draft( + DateTime(2026, 7, 15, 9), + DateTime(2026, 7, 15, 13), + 'America/New_York', + ).cloudAvailabilityInterval()!; + expect(summer.start.toUtc(), DateTime.utc(2026, 7, 15, 16)); + expect(summer.end.toUtc(), DateTime.utc(2026, 7, 15, 17)); + final transition = draft( + DateTime(2026, 3, 8, 1, 30), + DateTime(2026, 3, 8, 3, 30), + 'America/Los_Angeles', + ).cloudAvailabilityInterval()!; + expect(transition.start.toUtc(), DateTime.utc(2026, 3, 8, 9, 30)); + expect(transition.end.toUtc(), DateTime.utc(2026, 3, 8, 10, 30)); + }); + testWidgets('Microsoft description selection does not emit a body change', ( + tester, + ) async { + final changes = []; + await tester.pumpWidget( + localizedTestApp( + child: Scaffold( + body: EventDescriptionEditor( + provider: BusyProvider.microsoft, + text: 'Original agenda', + contentType: 'html', + html: '

Original agenda

', + onChanged: changes.add, + ), + ), + ), + ); + await tester.pumpAndSettle(); + final controller = tester + .widget(find.byType(TextField)) + .controller!; + controller.selection = const TextSelection(baseOffset: 0, extentOffset: 8); + await tester.pump(); + expect(changes, isEmpty); + await tester.tap(find.byTooltip('Bold').first); + await tester.pump(); + expect(changes, hasLength(1)); + expect(changes.single.html, contains('Original')); + }); + setUp(() { TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .setMockMethodCallHandler(_nativeDialogChannel, (_) async => null); @@ -72,6 +303,69 @@ void main() { expect(tester.widget(title).focusNode.hasFocus, isTrue); }); + testWidgets('Linux Google editor opens detailed guest availability', ( + tester, + ) async { + var requests = 0; + final client = GoogleCalendarApiClient( + httpClient: MockClient((request) async { + requests++; + expect(request.url.path, '/calendar/v3/freeBusy'); + return http.Response( + '{"calendars":{"guest@example.test":{"busy":[]}}}', + 200, + ); + }), + baseUri: Uri.parse('https://www.googleapis.com'), + ); + await tester.pumpWidget( + ProviderScope( + overrides: [ + calendarRemoteApiClientForAccountProvider( + 'account', + ).overrideWithValue(client), + ], + child: localizedTestApp( + child: Scaffold( + body: EventEditor( + initialDraft: EventEditorDraft.existing( + eventId: 'event-1', + accountId: 'account', + sourceId: 'source', + providerCalendarId: 'cal-1', + title: 'Planning', + allDay: false, + start: DateTime.utc(2026, 6, 8, 9), + end: DateTime.utc(2026, 6, 8, 10), + attendees: const [ + EventAttendeeDraft(email: 'guest@example.test'), + ], + ), + sources: _sources, + onCancel: () {}, + onSave: (_) {}, + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + final action = find.text('Check guest availability'); + await tester.ensureVisible(action); + await tester.tap(action); + await tester.pumpAndSettle(); + expect( + find.byKey(const ValueKey('linux-cloud-availability-dialog')), + findsOneWidget, + ); + expect(find.text('guest@example.test'), findsWidgets); + expect( + find.text('No busy periods reported for this interval'), + findsWidgets, + ); + expect(requests, 1); + }); + testWidgets( 'event location is an ordinary text field and restoring it keeps its point', (tester) async { @@ -2415,6 +2709,153 @@ void main() { expect(find.text('Categories'), findsNothing); }); + testWidgets('Linux Google editor selects a calendar-scoped event label', ( + tester, + ) async { + EventEditorDraft? saved; + await tester.pumpWidget( + ProviderScope( + overrides: [ + googleEventLabelsForCalendarProvider.overrideWith( + (ref, key) async => [ + const GoogleEventLabel( + id: 'label-1', + name: 'Project', + backgroundColor: '#336699', + ), + ], + ), + ], + child: localizedTestApp( + child: Scaffold( + body: EventEditor( + initialDraft: EventEditorDraft.newEvent( + accountId: 'account', + sourceId: 'source', + providerCalendarId: 'cal-1', + start: DateTime.utc(2026, 6, 8, 9), + end: DateTime.utc(2026, 6, 8, 10), + ).copyWith(title: 'Planning'), + sources: _sources, + onCancel: () {}, + onSave: (draft) => saved = draft, + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + _comboRow(tester, 'Event label').onSelected('label-1'); + await tester.pump(); + await tester.tap(_headerButtonFinder('Save')); + expect(saved?.eventLabelId, 'label-1'); + expect(saved?.eventLabelChanged, isTrue); + }); + + testWidgets('Linux primary Google editor creates native focus time', ( + tester, + ) async { + EventEditorDraft? saved; + await tester.pumpWidget( + ProviderScope( + overrides: [ + googleEventLabelsForCalendarProvider.overrideWith( + (ref, key) async => const [], + ), + ], + child: localizedTestApp( + child: Scaffold( + body: EventEditor( + initialDraft: EventEditorDraft.newEvent( + accountId: 'account', + sourceId: 'primary', + providerCalendarId: 'primary', + start: DateTime.utc(2026, 6, 8, 9), + end: DateTime.utc(2026, 6, 8, 10), + ).copyWith(title: 'Focus'), + sources: const [ + CalendarSourceEntity( + id: 'primary', + accountId: 'account', + provider: BusyProvider.google, + providerCalendarId: 'primary', + summary: 'Primary', + selected: true, + hidden: false, + readOnly: false, + isDeleted: false, + primaryCalendar: true, + ), + ], + onCancel: () {}, + onSave: (draft) => saved = draft, + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + _comboRow(tester, 'Event type').onSelected('focusTime'); + await tester.pump(); + _comboRow( + tester, + 'Decline overlapping invitations', + ).onSelected('declineOnlyNewConflictingInvitations'); + await tester.pump(); + await tester.tap(_headerButtonFinder('Save')); + expect(saved?.eventType, 'focusTime'); + expect(saved?.showAs, 'opaque'); + expect( + saved?.googleStatusProperties['autoDeclineMode'], + 'declineOnlyNewConflictingInvitations', + ); + }); + + testWidgets( + 'Linux Microsoft editor adds a master category without losing existing assignments', + (tester) async { + EventEditorDraft? saved; + await tester.pumpWidget( + ProviderScope( + overrides: [ + microsoftMasterCategoriesProvider.overrideWith( + (ref, accountId) async => [ + const MicrosoftMasterCategory( + id: 'c1', + displayName: 'Work', + color: 'preset7', + ), + ], + ), + ], + child: localizedTestApp( + child: Scaffold( + body: EventEditor( + initialDraft: EventEditorDraft.newEvent( + accountId: 'microsoft-account', + sourceId: 'microsoft-source', + providerCalendarId: 'ms-cal-1', + start: DateTime.utc(2026, 6, 8, 9), + end: DateTime.utc(2026, 6, 8, 10), + ).copyWith(title: 'Planning', categories: ['Existing']), + sources: _microsoftSources, + onCancel: () {}, + onSave: (draft) => saved = draft, + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + await tester.ensureVisible(find.text('Work').last); + await tester.pumpAndSettle(); + await tester.tap(find.text('Work').last); + await tester.pump(); + await tester.tap(_headerButtonFinder('Save')); + expect(saved?.categories, ['Existing', 'Work']); + }, + ); + testWidgets( 'removing a Microsoft event reminder disables provider reminder', (tester) async { diff --git a/test/features/notifications/notification_schedule_service_test.dart b/test/features/notifications/notification_schedule_service_test.dart index b432c08e..2c096477 100644 --- a/test/features/notifications/notification_schedule_service_test.dart +++ b/test/features/notifications/notification_schedule_service_test.dart @@ -417,6 +417,30 @@ void main() { ); }); + test( + 'pending Nextcloud reminder rows reconcile notifications before sync', + () async { + await _upsertEvent( + database, + accountId: 'nextcloud:n', + provider: BusyProvider.nextcloud, + remindersJson: const { + 'davEditableRows': [ + {'originalIndex': 0, 'minutes': 0}, + {'minutes': 30}, + ], + }, + ); + await service.rebuildUpcomingEventNotifications('nextcloud:n'); + final rows = await database.select(database.notificationSchedule).get(); + expect(rows, hasLength(2)); + expect(rows.map((row) => row.scheduledAtUtc).toSet(), { + DateTime.utc(2026, 6, 8, 9).millisecondsSinceEpoch, + DateTime.utc(2026, 6, 8, 8, 30).millisecondsSinceEpoch, + }); + }, + ); + test('Apple absolute DISPLAY alarm schedules a notification', () async { await _upsertEvent( database, @@ -481,6 +505,34 @@ void main() { ); }); + test('G start and zero-minute end DAV alarms project separately', () async { + await _upsertEvent( + database, + accountId: 'nextcloud:n', + provider: BusyProvider.nextcloud, + remindersJson: { + 'alarms': [ + _displayAlarm('-PT15M'), + _displayAlarm( + '-PT0M', + triggerParameters: [ + { + 'name': 'RELATED', + 'values': ['END'], + }, + ], + ), + ], + }, + ); + await service.rebuildUpcomingEventNotifications('nextcloud:n'); + final rows = await database.select(database.notificationSchedule).get(); + expect(rows.map((row) => row.scheduledAtUtc).toSet(), { + DateTime.utc(2026, 6, 8, 8, 45).millisecondsSinceEpoch, + DateTime.utc(2026, 6, 8, 10).millisecondsSinceEpoch, + }); + }); + test('DAV repeating alarm schedules every RFC repetition', () async { await _upsertEvent( database, diff --git a/test/features/schedule/attachment_download_test.dart b/test/features/schedule/attachment_download_test.dart new file mode 100644 index 00000000..30dbe5e6 --- /dev/null +++ b/test/features/schedule/attachment_download_test.dart @@ -0,0 +1,24 @@ +import 'package:busymax/src/features/schedule/presentation/attachment_download.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('provider filenames cannot select paths or control output', () { + expect(safeAttachmentFileName(' Agenda.pdf '), 'Agenda.pdf'); + for (final value in [ + '', + '.', + '..', + '../secret', + r'..\secret', + 'report?.pdf', + 'report:part.pdf', + 'report.', + 'CON.txt', + 'lpt1', + 'line\nbreak', + String.fromCharCodes(List.filled(256, 97)), + ]) { + expect(safeAttachmentFileName(value), isNull, reason: value); + } + }); +} diff --git a/test/features/schedule/event_attachment_link_test.dart b/test/features/schedule/event_attachment_link_test.dart new file mode 100644 index 00000000..53e83474 --- /dev/null +++ b/test/features/schedule/event_attachment_link_test.dart @@ -0,0 +1,22 @@ +import 'package:busymax/src/schedule/event_attachment_link.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('projects only safe provider-supplied attachment URLs', () { + final links = eventAttachmentLinks(const [ + { + 'fileUrl': 'https://drive.example/file', + 'title': 'Agenda', + 'mimeType': 'application/pdf', + }, + {'fileUrl': 'https://drive.example/file', 'title': 'Duplicate'}, + 'https://cloud.example/documents/notes.txt', + 'javascript:alert(1)', + 'https://user:password@cloud.example/secret', + 'data:text/plain;base64,YQ==', + ]); + expect(links.map((link) => link.name), ['Agenda', 'notes.txt']); + expect(links.first.mimeType, 'application/pdf'); + expect(links.last.url, 'https://cloud.example/documents/notes.txt'); + }); +} diff --git a/test/features/schedule/presentation/cloud_calendar_series_export_test.dart b/test/features/schedule/presentation/cloud_calendar_series_export_test.dart new file mode 100644 index 00000000..e5bcc543 --- /dev/null +++ b/test/features/schedule/presentation/cloud_calendar_series_export_test.dart @@ -0,0 +1,985 @@ +import 'dart:convert'; + +import 'package:busymax/src/calendar_providers/calendar_sync_dto.dart'; +import 'package:busymax/src/dav/ical/ical_semantics.dart'; +import 'package:busymax/src/dav/ical/ical_timezone.dart'; +import 'package:busymax/src/features/schedule/presentation/cloud_calendar_series_export.dart'; +import 'package:busymax/src/google_calendar/google_calendar_api_client.dart'; +import 'package:busymax/src/google_calendar/google_calendar_mapper.dart'; +import 'package:busymax/src/ical/ical_ingestion.dart'; +import 'package:busymax/src/microsoft_calendar/microsoft_calendar_api_client.dart'; +import 'package:busymax/src/microsoft_calendar/microsoft_calendar_mapper.dart'; +import 'package:busymax/src/providers/busy_provider.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:timezone/data/latest_all.dart' as time_zone_data; +import 'package:timezone/timezone.dart' as tz; + +void main() { + // Resolve through the exported VTIMEZONE, not timezone's IANA fallback. + DateTime exportedInstant(String data, String zone, DateTime wall) { + final semantic = IcalIngestion.parseString( + data, + policy: IcalIngestionPolicy.fileImport, + ).recurrenceSets.single.semantic; + expect( + semantic.timeZones.any( + (component) => component.firstProperty('TZID')?.rawValue == zone, + ), + isTrue, + ); + return IcalTimeZoneResolver.fromDocument(semantic).toUtc( + IcalTemporalValue( + rawValue: '', + kind: IcalTemporalKind.tzidDateTime, + localValue: wall, + timeZoneId: zone, + ), + ); + } + + CalendarEventDto zonedMicrosoftSeries({ + required String zone, + required String startUtc, + required Map range, + }) => microsoftCalendarEventFromJson('calendar', { + ..._microsoftMaster, + 'start': {'dateTime': startUtc, 'timeZone': 'UTC'}, + 'end': { + 'dateTime': DateTime.parse( + startUtc, + ).add(const Duration(hours: 1)).toIso8601String(), + 'timeZone': 'UTC', + }, + 'recurrence': { + 'pattern': {'type': 'daily', 'interval': 1}, + 'range': {...range, 'recurrenceTimeZone': zone}, + }, + }); + + test('IANA 2025c Apia ending DST never invents later daylight', () { + final data = cloudSeriesToICalendar( + master: zonedMicrosoftSeries( + zone: 'Pacific/Apia', + startUtc: '2018-12-31T20:00:00', + range: {'type': 'noEnd', 'startDate': '2019-01-01'}, + ), + exceptions: const [], + nowUtc: DateTime.utc(2026), + ); + expect(data, contains('TZID:Pacific/Apia')); + expect(data, contains('TZOFFSETTO:+1400')); + expect( + exportedInstant(data, 'Pacific/Apia', DateTime.utc(2022, 1, 15, 9)), + DateTime.utc(2022, 1, 14, 20), + ); + expect( + exportedInstant(data, 'Pacific/Apia', DateTime.utc(2040, 1, 15, 9)), + DateTime.utc(2040, 1, 14, 20), + ); + }); + + test('IANA 2025c finite Los Angeles COUNT and UNTIL reach 2038', () { + time_zone_data.initializeTimeZones(); + expect( + DateTime.fromMillisecondsSinceEpoch( + tz.getLocation('America/Los_Angeles').transitionAt.last, + isUtc: true, + ).year, + 2037, + ); + for (final range in [ + { + 'type': 'numbered', + 'startDate': '2030-01-01', + 'numberOfOccurrences': 3300, + }, + {'type': 'endDate', 'startDate': '2030-01-01', 'endDate': '2038-12-31'}, + ]) { + final data = cloudSeriesToICalendar( + master: zonedMicrosoftSeries( + zone: 'America/Los_Angeles', + startUtc: '2030-01-01T17:00:00', + range: range, + ), + exceptions: const [], + nowUtc: DateTime.utc(2026), + ); + expect( + exportedInstant( + data, + 'America/Los_Angeles', + DateTime.utc(2038, 1, 15, 9), + ), + DateTime.utc(2038, 1, 15, 17), + ); + expect( + exportedInstant( + data, + 'America/Los_Angeles', + DateTime.utc(2038, 7, 15, 9), + ), + DateTime.utc(2038, 7, 15, 16), + ); + final futureObservance = RegExp( + r'DTSTART:2038[^\r\n]*\r\nTZOFFSETFROM:-0800\r\nTZOFFSETTO:-0700', + ).firstMatch(data)!; + final corrupted = data.replaceRange( + futureObservance.start, + futureObservance.end, + futureObservance + .group(0)! + .replaceFirst('TZOFFSETTO:-0700', 'TZOFFSETTO:-0600'), + ); + expect(corrupted, isNot(data)); + expect( + exportedInstant( + corrupted, + 'America/Los_Angeles', + DateTime.utc(2038, 7, 15, 9), + ), + isNot(DateTime.utc(2038, 7, 15, 16)), + ); + } + }); + + test('IANA 2025c open-ended DST and fixed offsets remain distinct', () { + final losAngeles = cloudSeriesToICalendar( + master: zonedMicrosoftSeries( + zone: 'America/Los_Angeles', + startUtc: '2030-01-01T17:00:00', + range: {'type': 'noEnd', 'startDate': '2030-01-01'}, + ), + exceptions: const [], + nowUtc: DateTime.utc(2026), + ); + expect( + exportedInstant( + losAngeles, + 'America/Los_Angeles', + DateTime.utc(2040, 1, 15, 9), + ), + DateTime.utc(2040, 1, 15, 17), + ); + expect( + exportedInstant( + losAngeles, + 'America/Los_Angeles', + DateTime.utc(2040, 7, 15, 9), + ), + DateTime.utc(2040, 7, 15, 16), + ); + final kolkata = cloudSeriesToICalendar( + master: zonedMicrosoftSeries( + zone: 'Asia/Kolkata', + startUtc: '2030-01-01T03:30:00', + range: {'type': 'noEnd', 'startDate': '2030-01-01'}, + ), + exceptions: const [], + nowUtc: DateTime.utc(2026), + ); + expect(kolkata, isNot(contains('BEGIN:DAYLIGHT'))); + expect( + exportedInstant(kolkata, 'Asia/Kolkata', DateTime.utc(2040, 7, 15, 9)), + DateTime.utc(2040, 7, 15, 3, 30), + ); + }); + + test( + 'a series starting after the 2037 table keeps its authoritative wall time and UNTIL', + () { + final data = cloudSeriesToICalendar( + master: zonedMicrosoftSeries( + zone: 'America/Los_Angeles', + startUtc: '2038-07-01T16:00:00', + range: { + 'type': 'endDate', + 'startDate': '2038-07-01', + 'endDate': '2038-07-31', + }, + ), + exceptions: const [], + nowUtc: DateTime.utc(2026), + ); + expect( + data, + contains('DTSTART;TZID=America/Los_Angeles:20380701T090000'), + ); + expect(data, contains('UNTIL=20380731T160000Z')); + expect( + exportedInstant( + data, + 'America/Los_Angeles', + DateTime.utc(2038, 7, 31, 9), + ), + DateTime.utc(2038, 7, 31, 16), + ); + }, + ); + + test('IANA 2025c all-year DST continuation stays constant', () { + for (final zone in ['Africa/Casablanca', 'Africa/El_Aaiun']) { + final openEnded = cloudSeriesToICalendar( + master: zonedMicrosoftSeries( + zone: zone, + startUtc: '2026-01-01T08:00:00', + range: {'type': 'noEnd', 'startDate': '2026-01-01'}, + ), + exceptions: const [], + nowUtc: DateTime.utc(2026), + ); + // Historical Ramadan suspension remains represented before the TZif + // continuation boundary. + expect( + exportedInstant(openEnded, zone, DateTime.utc(2026, 3, 1, 9)), + DateTime.utc(2026, 3, 1, 9), + ); + expect( + exportedInstant(openEnded, zone, DateTime.utc(2026, 6, 1, 9)), + DateTime.utc(2026, 6, 1, 8), + ); + for (final wall in [ + DateTime.utc(2088, 1, 1, 9), + DateTime.utc(2088, 6, 1, 9), + DateTime.utc(2088, 12, 31, 9), + DateTime.utc(2089, 1, 1, 9), + DateTime.utc(2089, 6, 1, 9), + ]) { + expect( + exportedInstant(openEnded, zone, wall), + wall.subtract(const Duration(hours: 1)), + reason: '$zone $wall must retain all-year daylight offset', + ); + } + expect( + RegExp( + r'DTSTART:208[89][^\r\n]*\r\nTZOFFSETFROM:\+0000\r\nTZOFFSETTO:\+0100|' + r'DTSTART:208[89][^\r\n]*\r\nTZOFFSETFROM:\+0100\r\nTZOFFSETTO:\+0000', + ).allMatches(openEnded), + isEmpty, + ); + + final finite = cloudSeriesToICalendar( + master: zonedMicrosoftSeries( + zone: zone, + startUtc: '2088-06-01T08:00:00', + range: { + 'type': 'endDate', + 'startDate': '2088-06-01', + 'endDate': '2088-12-31', + }, + ), + exceptions: const [], + nowUtc: DateTime.utc(2026), + ); + expect(finite, contains('DTSTART;TZID=$zone:20880601T090000')); + expect(finite, contains('UNTIL=20881231T080000Z')); + expect( + exportedInstant(finite, zone, DateTime.utc(2088, 6, 1, 9)), + DateTime.utc(2088, 6, 1, 8), + ); + } + }); + test('series export is offered only with an authoritative series path', () { + expect( + canExportAuthoritativeEventSeries( + provider: BusyProvider.google, + providerEventId: 'master', + davCollectionId: null, + ), + isTrue, + ); + expect( + canExportAuthoritativeEventSeries( + provider: BusyProvider.microsoft, + providerEventId: null, + davCollectionId: null, + ), + isFalse, + ); + expect( + canExportAuthoritativeEventSeries( + provider: BusyProvider.nextcloud, + providerEventId: null, + davCollectionId: 'collection', + ), + isTrue, + ); + expect( + canExportAuthoritativeEventSeries( + provider: BusyProvider.webCal, + providerEventId: 'cached-event', + davCollectionId: null, + ), + isFalse, + ); + }); + + test( + 'Google series export waits for every page and retains moved/cancelled instances', + () async { + final requests = []; + final client = GoogleCalendarApiClient( + httpClient: MockClient((request) async { + requests.add(request); + if (request.url.path.endsWith('/events/master')) { + return http.Response(jsonEncode(_googleMaster), 200); + } + if (request.url.queryParameters['pageToken'] == 'second') { + return http.Response( + jsonEncode({ + 'items': [_googleMoved, _googleCancelled], + }), + 200, + ); + } + return http.Response( + jsonEncode({ + 'items': [_googleMaster], + 'nextPageToken': 'second', + }), + 200, + ); + }), + baseUri: Uri.parse('https://www.googleapis.com'), + ); + final data = await exportGoogleEventSeries( + client: client, + calendarId: 'primary', + eventId: 'master', + nowUtc: DateTime.utc(2026, 8, 1), + ); + expect( + requests.where((r) => r.url.queryParameters['pageToken'] == 'second'), + hasLength(1), + ); + expect( + requests.where((r) => r.url.queryParameters['showDeleted'] == 'true'), + hasLength(2), + ); + expect( + requests.where((r) => r.url.path.endsWith('/calendarList')), + isEmpty, + ); + expect(data, contains('UID:series@example.test')); + expect(data, contains('RRULE:FREQ=DAILY;COUNT=3')); + expect( + data, + contains('RECURRENCE-ID;TZID=America/Los_Angeles:20260831T090000'), + ); + expect(data, contains('SUMMARY:Moved')); + expect(data, contains('STATUS:CANCELLED')); + expect(data, contains('BEGIN:VALARM')); + expect(data, contains('ATTENDEE;ROLE=OPT-PARTICIPANT;PARTSTAT=ACCEPTED')); + expect(data, contains('ATTACH:https://files.example.test/agenda.pdf')); + final parsed = IcalIngestion.parseString( + data, + policy: IcalIngestionPolicy.fileImport, + ); + expect(parsed.recurrenceSets.single.semantic.components, hasLength(3)); + }, + ); + + test('Google later-page failure does not return a partial series', () async { + final client = GoogleCalendarApiClient( + httpClient: MockClient((request) async { + if (request.url.path.endsWith('/events/master')) { + return http.Response(jsonEncode(_googleMaster), 200); + } + if (request.url.queryParameters['pageToken'] == 'second') { + return http.Response('later page failed', 503); + } + return http.Response( + jsonEncode({ + 'items': [_googleMaster], + 'nextPageToken': 'second', + }), + 200, + ); + }), + baseUri: Uri.parse('https://www.googleapis.com'), + ); + await expectLater( + exportGoogleEventSeries( + client: client, + calendarId: 'primary', + eventId: 'master', + nowUtc: DateTime.utc(2026, 8, 1), + ), + throwsA(isA()), + ); + }); + + test( + 'Microsoft series export reads full exceptions and cancellation identities', + () async { + final requests = []; + final client = MicrosoftCalendarApiClient( + httpClient: MockClient((request) async { + requests.add(request); + if (request.url.queryParameters.containsKey(r'$expand')) { + return http.Response( + jsonEncode({ + 'exceptionOccurrences': [ + {'id': 'moved', 'subject': 'Moved'}, + ], + 'cancelledOccurrences': ['OID.master.2026-09-01'], + }), + 200, + ); + } + if (request.url.path.endsWith('/events/moved')) { + return http.Response( + jsonEncode({ + ..._microsoftMaster, + 'id': 'moved', + 'type': 'exception', + 'seriesMasterId': 'master', + 'originalStart': '2026-08-31T16:00:00Z', + 'subject': 'Moved', + 'start': {'dateTime': '2026-08-31T18:00:00', 'timeZone': 'UTC'}, + 'end': {'dateTime': '2026-08-31T19:00:00', 'timeZone': 'UTC'}, + 'recurrence': null, + }), + 200, + ); + } + return http.Response(jsonEncode(_microsoftMaster), 200); + }), + baseUri: Uri.parse('https://graph.microsoft.com/v1.0'), + responseTimeZone: 'UTC', + ); + final data = await exportMicrosoftEventSeries( + client: client, + calendarId: 'calendar', + eventId: 'master', + nowUtc: DateTime.utc(2026, 8, 1), + ); + expect( + requests.where((r) => r.url.path.endsWith('/events/moved')), + hasLength(1), + ); + expect(data, contains('RRULE:FREQ=DAILY;INTERVAL=1;COUNT=3')); + expect( + data, + contains('DTSTART;TZID=America/Los_Angeles:20260830T090000'), + ); + expect( + data, + contains('RECURRENCE-ID;TZID=America/Los_Angeles:20260831T090000'), + ); + expect(data, contains('SUMMARY:Moved')); + expect(data, contains('STATUS:CANCELLED')); + expect( + IcalIngestion.parseString( + data, + policy: IcalIngestionPolicy.fileImport, + ).recurrenceSets.single.semantic.components, + hasLength(3), + ); + }, + ); + + test( + 'Microsoft series uses recurrence zone and emits a valid timed UNTIL', + () { + final master = microsoftCalendarEventFromJson('calendar', { + ..._microsoftMaster, + 'start': {'dateTime': '2026-08-30T16:00:00', 'timeZone': 'UTC'}, + 'end': {'dateTime': '2026-08-30T17:00:00', 'timeZone': 'UTC'}, + 'recurrence': { + 'pattern': {'type': 'daily', 'interval': 1}, + 'range': { + 'type': 'endDate', + 'startDate': '2026-08-30', + 'endDate': '2026-09-01', + 'recurrenceTimeZone': 'Pacific Standard Time', + }, + }, + }); + final data = cloudSeriesToICalendar( + master: master, + exceptions: const [], + nowUtc: DateTime.utc(2026, 8, 1), + ); + expect( + data, + contains('DTSTART;TZID=America/Los_Angeles:20260830T090000'), + ); + expect(data, contains('BEGIN:VTIMEZONE')); + expect(data, contains('TZID:America/Los_Angeles')); + expect(data, contains('UNTIL=20260901T160000Z')); + expect(data, isNot(contains('UNTIL=20260901;'))); + time_zone_data.initializeTimeZones(); + final location = tz.getLocation('America/Los_Angeles'); + final until = DateTime.utc(2026, 9, 1, 16); + final occurrences = [ + for (var day = 30; day <= 33; day++) + tz.TZDateTime(location, 2026, 8, day, 9).toUtc(), + ]; + expect( + occurrences.take(3).every((instant) => !instant.isAfter(until)), + isTrue, + ); + expect(occurrences.last.isAfter(until), isTrue); + final references = RegExp( + r';TZID=([^:]+):', + ).allMatches(data).map((match) => match.group(1)!).toSet(); + final definitions = RegExp( + r'^TZID:([^\r\n]+)', + multiLine: true, + ).allMatches(data).map((match) => match.group(1)!).toSet(); + expect(definitions, containsAll(references)); + }, + ); + + test( + 'Microsoft no-end series keeps wall time across DST in response UTC', + () { + final master = microsoftCalendarEventFromJson('calendar', { + ..._microsoftMaster, + 'start': {'dateTime': '2026-10-31T16:00:00', 'timeZone': 'UTC'}, + 'end': {'dateTime': '2026-10-31T17:00:00', 'timeZone': 'UTC'}, + 'recurrence': { + 'pattern': {'type': 'daily', 'interval': 1}, + 'range': { + 'type': 'noEnd', + 'startDate': '2026-10-31', + 'recurrenceTimeZone': 'Pacific Standard Time', + }, + }, + }); + final data = cloudSeriesToICalendar( + master: master, + exceptions: const [], + nowUtc: DateTime.utc(2026, 10, 1), + ); + expect( + data, + contains('DTSTART;TZID=America/Los_Angeles:20261031T090000'), + ); + expect(data, contains('RRULE:FREQ=DAILY;INTERVAL=1')); + expect(data, isNot(contains('UNTIL='))); + expect(data, contains('BEGIN:DAYLIGHT')); + expect(data, contains('BEGIN:STANDARD')); + expect(data, contains('TZOFFSETTO:-0700')); + expect(data, contains('TZOFFSETTO:-0800')); + time_zone_data.initializeTimeZones(); + final location = tz.getLocation('America/Los_Angeles'); + final match = RegExp( + r'DTSTART;TZID=America/Los_Angeles:(\d{8})T(\d{6})', + ).firstMatch(data)!; + final date = match.group(1)!; + final time = match.group(2)!; + final year = int.parse(date.substring(0, 4)); + final month = int.parse(date.substring(4, 6)); + final day = int.parse(date.substring(6, 8)); + final hour = int.parse(time.substring(0, 2)); + expect( + [ + for (var offset = 0; offset < 3; offset++) + tz.TZDateTime(location, year, month, day + offset, hour).toUtc(), + ], + [ + DateTime.utc(2026, 10, 31, 16), + DateTime.utc(2026, 11, 1, 17), + DateTime.utc(2026, 11, 2, 17), + ], + ); + expect( + RegExp( + r'BEGIN:(?:STANDARD|DAYLIGHT)[\s\S]*?RRULE:FREQ=YEARLY', + ).hasMatch(data), + isTrue, + ); + }, + ); + + test('Microsoft all-day end-date series retains DATE UNTIL', () { + final master = microsoftCalendarEventFromJson('calendar', { + ..._microsoftMaster, + 'isAllDay': true, + 'start': { + 'dateTime': '2026-08-30T00:00:00', + 'timeZone': 'Pacific Standard Time', + }, + 'end': { + 'dateTime': '2026-08-31T00:00:00', + 'timeZone': 'Pacific Standard Time', + }, + 'recurrence': { + 'pattern': {'type': 'daily', 'interval': 1}, + 'range': { + 'type': 'endDate', + 'startDate': '2026-08-30', + 'endDate': '2026-09-01', + 'recurrenceTimeZone': 'Pacific Standard Time', + }, + }, + }); + final data = cloudSeriesToICalendar( + master: master, + exceptions: const [], + nowUtc: DateTime.utc(2026, 8, 1), + ); + expect(data, contains('DTSTART;VALUE=DATE:20260830')); + expect(data, contains('RRULE:FREQ=DAILY;INTERVAL=1;UNTIL=20260901')); + }); + + test('series export refuses opaque provider IDs as iCalendar UIDs', () { + expect( + () => cloudSeriesToICalendar( + master: const CalendarEventDto( + provider: BusyProvider.google, + providerCalendarId: 'primary', + providerEventId: 'opaque', + title: 'Meeting', + startDate: '2026-08-30', + endDate: '2026-08-31', + allDay: true, + ), + exceptions: const [], + nowUtc: DateTime.utc(2026, 8, 1), + ), + throwsFormatException, + ); + }); + + test( + 'series export retains calendar-default popup alarms only with authoritative settings', + () async { + final master = { + ..._googleMaster, + 'reminders': {'useDefault': true}, + }; + final client = GoogleCalendarApiClient( + httpClient: MockClient((request) async { + if (request.url.path.endsWith('/events/master')) { + return http.Response(jsonEncode(master), 200); + } + if (request.url.path.endsWith('/users/me/calendarList')) { + return http.Response( + jsonEncode({ + 'items': [ + { + 'id': 'primary', + 'summary': 'Primary', + 'defaultReminders': [ + {'method': 'popup', 'minutes': 5}, + ], + }, + ], + }), + 200, + ); + } + return http.Response( + jsonEncode({ + 'items': [master], + }), + 200, + ); + }), + baseUri: Uri.parse('https://www.googleapis.com'), + ); + final exported = await exportGoogleEventSeries( + client: client, + calendarId: 'primary', + eventId: 'master', + nowUtc: DateTime.utc(2026, 8, 1), + ); + expect(exported, contains('TRIGGER:-PT5M')); + expect( + () => cloudSeriesToICalendar( + master: googleCalendarEventFromJson('primary', master), + exceptions: const [], + nowUtc: DateTime.utc(2026, 8, 1), + ), + throwsFormatException, + ); + }, + ); + + test( + 'E Google exception defaults are fetched when master is explicit', + () async { + var defaultsReads = 0; + final moved = { + ..._googleMoved, + 'reminders': {'useDefault': true}, + }; + final client = GoogleCalendarApiClient( + httpClient: MockClient((request) async { + if (request.url.path.endsWith('/events/master')) { + return http.Response(jsonEncode(_googleMaster), 200); + } + if (request.url.path.endsWith('/users/me/calendarList')) { + defaultsReads++; + return http.Response( + jsonEncode({ + 'items': [ + { + 'id': 'primary', + 'summary': 'Primary', + 'defaultReminders': [ + {'method': 'popup', 'minutes': 5}, + ], + }, + ], + }), + 200, + ); + } + return http.Response( + jsonEncode({ + 'items': [_googleMaster, moved], + }), + 200, + ); + }), + baseUri: Uri.parse('https://www.googleapis.com'), + ); + final exported = await exportGoogleEventSeries( + client: client, + calendarId: 'primary', + eventId: 'master', + nowUtc: DateTime.utc(2026, 8, 1), + ); + expect(defaultsReads, 1); + expect(RegExp(r'TRIGGER:-PT10M').allMatches(exported), hasLength(1)); + expect(RegExp(r'TRIGGER:-PT5M').allMatches(exported), hasLength(1)); + }, + ); + + test('Google exception inherits absent or null empty defaults', () async { + for (final calendar in >[ + {'id': 'primary', 'summary': 'Primary'}, + {'id': 'primary', 'summary': 'Primary', 'defaultReminders': null}, + ]) { + var defaultReads = 0; + final moved = { + ..._googleMoved, + 'reminders': {'useDefault': true}, + }; + final client = GoogleCalendarApiClient( + httpClient: MockClient((request) async { + if (request.url.path.endsWith('/events/master')) { + return http.Response(jsonEncode(_googleMaster), 200); + } + if (request.url.path.endsWith('/users/me/calendarList')) { + defaultReads++; + return http.Response( + jsonEncode({ + 'items': [calendar], + }), + 200, + ); + } + return http.Response( + jsonEncode({ + 'items': [_googleMaster, moved], + }), + 200, + ); + }), + baseUri: Uri.parse('https://www.googleapis.com'), + ); + final exported = await exportGoogleEventSeries( + client: client, + calendarId: 'primary', + eventId: 'master', + nowUtc: DateTime.utc(2026, 8, 1), + ); + final events = RegExp( + r'BEGIN:VEVENT[\s\S]*?END:VEVENT', + ).allMatches(exported).map((match) => match.group(0)!).toList(); + expect(defaultReads, 1); + expect(events, hasLength(2)); + expect(events.first, contains('TRIGGER:-PT10M')); + expect(events.last, isNot(contains('BEGIN:VALARM'))); + } + }); + + test('Google master inherits an empty default reminder set', () async { + final master = { + ..._googleMaster, + 'reminders': {'useDefault': true}, + }; + final client = GoogleCalendarApiClient( + httpClient: MockClient((request) async { + if (request.url.path.endsWith('/events/master')) { + return http.Response(jsonEncode(master), 200); + } + if (request.url.path.endsWith('/users/me/calendarList')) { + return http.Response( + jsonEncode({ + 'items': [ + {'id': 'primary', 'summary': 'Primary'}, + ], + }), + 200, + ); + } + return http.Response( + jsonEncode({ + 'items': [master], + }), + 200, + ); + }), + baseUri: Uri.parse('https://www.googleapis.com'), + ); + final exported = await exportGoogleEventSeries( + client: client, + calendarId: 'primary', + eventId: 'master', + nowUtc: DateTime.utc(2026, 8, 1), + ); + expect(exported, isNot(contains('BEGIN:VALARM'))); + }); + + test( + 'Google default reminder lookup rejects absent and malformed calendars', + () async { + final moved = { + ..._googleMoved, + 'reminders': {'useDefault': true}, + }; + for (final calendars in >>[ + [ + {'id': 'other', 'summary': 'Other'}, + ], + [ + {'id': 'primary', 'summary': 'Primary', 'defaultReminders': 'bad'}, + ], + [ + { + 'id': 'primary', + 'summary': 'Primary', + 'defaultReminders': [42], + }, + ], + ]) { + final client = GoogleCalendarApiClient( + httpClient: MockClient((request) async { + if (request.url.path.endsWith('/events/master')) { + return http.Response(jsonEncode(_googleMaster), 200); + } + if (request.url.path.endsWith('/users/me/calendarList')) { + return http.Response(jsonEncode({'items': calendars}), 200); + } + return http.Response( + jsonEncode({ + 'items': [_googleMaster, moved], + }), + 200, + ); + }), + baseUri: Uri.parse('https://www.googleapis.com'), + ); + await expectLater( + exportGoogleEventSeries( + client: client, + calendarId: 'primary', + eventId: 'master', + nowUtc: DateTime.utc(2026, 8, 1), + ), + throwsA(isA()), + ); + } + }, + ); + + test('series export rejects a floating cloud time without an event zone', () { + expect( + () => cloudSeriesToICalendar( + master: const CalendarEventDto( + provider: BusyProvider.google, + providerCalendarId: 'primary', + providerEventId: 'master', + title: 'Floating', + startDateTime: '2026-08-30T09:00:00', + endDateTime: '2026-08-30T10:00:00', + rawJson: {'iCalUID': 'floating@example.test'}, + ), + exceptions: const [], + nowUtc: DateTime.utc(2026, 8, 1), + ), + throwsFormatException, + ); + }); +} + +const _googleMaster = { + 'id': 'master', + 'iCalUID': 'series@example.test', + 'summary': 'Master', + 'start': { + 'dateTime': '2026-08-30T09:00:00-07:00', + 'timeZone': 'America/Los_Angeles', + }, + 'end': { + 'dateTime': '2026-08-30T10:00:00-07:00', + 'timeZone': 'America/Los_Angeles', + }, + 'recurrence': ['RRULE:FREQ=DAILY;COUNT=3'], + 'reminders': { + 'useDefault': false, + 'overrides': [ + {'method': 'popup', 'minutes': 10}, + ], + }, + 'attendees': [ + { + 'email': 'guest@example.test', + 'optional': true, + 'responseStatus': 'accepted', + }, + ], + 'attachments': [ + {'fileUrl': 'https://files.example.test/agenda.pdf'}, + ], +}; + +const _googleMoved = { + 'id': 'moved', + 'iCalUID': 'series@example.test', + 'recurringEventId': 'master', + 'originalStartTime': {'dateTime': '2026-08-31T09:00:00-07:00'}, + 'summary': 'Moved', + 'start': { + 'dateTime': '2026-08-31T11:00:00-07:00', + 'timeZone': 'America/Los_Angeles', + }, + 'end': { + 'dateTime': '2026-08-31T12:00:00-07:00', + 'timeZone': 'America/Los_Angeles', + }, +}; + +const _googleCancelled = { + 'id': 'cancelled', + 'iCalUID': 'series@example.test', + 'recurringEventId': 'master', + 'originalStartTime': {'dateTime': '2026-09-01T09:00:00-07:00'}, + 'status': 'cancelled', +}; + +const _microsoftMaster = { + 'id': 'master', + 'uid': 'series@example.test', + 'subject': 'Master', + 'type': 'seriesMaster', + 'start': {'dateTime': '2026-08-30T16:00:00', 'timeZone': 'UTC'}, + 'end': {'dateTime': '2026-08-30T17:00:00', 'timeZone': 'UTC'}, + 'recurrence': { + 'pattern': {'type': 'daily', 'interval': 1}, + 'range': { + 'type': 'numbered', + 'startDate': '2026-08-30', + 'numberOfOccurrences': 3, + 'recurrenceTimeZone': 'Pacific Standard Time', + }, + }, +}; diff --git a/test/features/schedule/presentation/schedule_search_titlebar_test.dart b/test/features/schedule/presentation/schedule_search_titlebar_test.dart index aed183dc..5268b7e8 100644 --- a/test/features/schedule/presentation/schedule_search_titlebar_test.dart +++ b/test/features/schedule/presentation/schedule_search_titlebar_test.dart @@ -827,6 +827,12 @@ class _MemorySettingsStore implements LocalSettingsStore { class _RecordingScheduleRepository implements ScheduleRepository { final queries = []; + @override + bool? cloudCoverageCompleteFor( + ScheduleRange range, { + ScheduleFilters filters = const ScheduleFilters(), + }) => null; + @override Future> listItems({ required ScheduleRange range, diff --git a/test/features/schedule/presentation/schedule_views_test.dart b/test/features/schedule/presentation/schedule_views_test.dart index 68205b4a..993e1426 100644 --- a/test/features/schedule/presentation/schedule_views_test.dart +++ b/test/features/schedule/presentation/schedule_views_test.dart @@ -12,6 +12,7 @@ import 'package:busymax/src/ui/windows/windows_schedule_page.dart'; import 'package:fluent_ui/fluent_ui.dart' as fluent; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:intl/intl.dart'; +import 'package:intl/date_symbol_data_local.dart'; import 'package:busymax/src/app/busymax_yaru_theme.dart'; import 'package:busymax/src/features/schedule/presentation/calendar_day_semantics.dart'; import 'package:busymax/src/features/schedule/presentation/schedule_agenda_view.dart'; @@ -20,6 +21,7 @@ import 'package:busymax/src/features/schedule/presentation/schedule_day_week_vie import 'package:busymax/src/features/schedule/presentation/schedule_event_block.dart'; import 'package:busymax/src/features/schedule/presentation/schedule_item_chip.dart'; import 'package:busymax/src/features/schedule/presentation/schedule_item_details_popover.dart'; +import 'package:busymax/src/features/schedule/presentation/schedule_event_details_format.dart'; import 'package:busymax/src/features/schedule/presentation/schedule_item_exporter.dart'; import 'package:busymax/src/features/schedule/presentation/mini_calendar.dart'; import 'package:busymax/src/features/schedule/presentation/schedule_month_view.dart'; @@ -28,6 +30,7 @@ import 'package:busymax/src/features/tasks/domain/task_checklist_item.dart'; 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/event_attachment_link.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'; @@ -67,6 +70,12 @@ class _EmptyCalendarSources implements CalendarRepository { } class _TestScheduleItems implements ScheduleRepository { + @override + bool? cloudCoverageCompleteFor( + ScheduleRange range, { + ScheduleFilters filters = const ScheduleFilters(), + }) => null; + @override Future> listItems({ required ScheduleRange range, @@ -77,6 +86,7 @@ class _TestScheduleItems implements ScheduleRepository { } void main() { + setUpAll(initializeDateFormatting); scheduleDateGestureTests( 'Linux', (scenario) => localizedTestApp( @@ -1702,6 +1712,116 @@ void main() { expect(await action, ScheduleItemDetailsAction.joinMeeting); }); + testWidgets( + 'detail interval labels the displayed local time, not event zones', + (tester) async { + final event = CalendarScheduleItem( + id: 'zoned-event', + accountId: 'account', + provider: BusyProvider.microsoft, + sourceId: 'calendar', + providerCalendarId: 'calendar', + title: 'Cross-zone meeting', + allDay: false, + start: DateTime.utc(2026, 7, 1, 12).toLocal(), + end: DateTime.utc(2026, 7, 1, 13).toLocal(), + startTimeZone: 'Pacific/Honolulu', + endTimeZone: 'Asia/Tokyo', + capabilities: ScheduleItemCapabilities.readOnly, + ); + await tester.pumpWidget( + localizedTestApp( + child: Builder( + builder: (context) => + Text(scheduleEventIntervalLabel(context, event)), + ), + ), + ); + final label = tester.widget(find.byType(Text)).data!; + expect(label, contains('UTC')); + expect(label, isNot(contains('Pacific/Honolulu'))); + expect(label, isNot(contains('Asia/Tokyo'))); + }, + ); + + testWidgets('floating DAV detail without TZID uses the local offset', ( + tester, + ) async { + final event = CalendarScheduleItem( + id: 'floating-event', + accountId: 'account', + provider: BusyProvider.nextcloud, + sourceId: 'calendar', + providerCalendarId: 'calendar', + title: 'Floating event', + allDay: false, + start: DateTime(2026, 3, 8, 1, 30), + end: DateTime(2026, 3, 8, 3, 30), + capabilities: ScheduleItemCapabilities.readOnly, + ); + await tester.pumpWidget( + localizedTestApp( + child: Builder( + builder: (context) => + Text(scheduleEventIntervalLabel(context, event)), + ), + ), + ); + expect(tester.widget(find.byType(Text)).data, contains('UTC')); + }); + + testWidgets('all-day details end on the last included day and expose links', ( + tester, + ) async { + final event = CalendarScheduleItem( + id: 'event-link', + accountId: 'account', + provider: BusyProvider.nextcloud, + sourceId: 'calendar', + providerCalendarId: 'calendar', + title: 'Conference', + allDay: true, + start: DateTime(2026, 3, 8), + end: DateTime(2026, 3, 10), + description: 'Read https://example.com/agenda', + eventLinkUrl: 'https://example.com/event', + attachmentLinks: const [ + EventAttachmentLink( + url: 'https://example.com/notes', + name: 'Notes attachment', + ), + ], + attachmentsLoaded: true, + attachmentsMayExist: true, + capabilities: ScheduleItemCapabilities.readOnly, + ); + await tester.pumpWidget( + localizedTestApp( + child: Scaffold( + body: Builder( + builder: (context) => TextButton( + onPressed: () => showScheduleItemDetailsPopover( + context: context, + anchorContext: context, + item: event, + ), + child: const Text('Open details'), + ), + ), + ), + ), + ); + await tester.tap(find.text('Open details')); + await tester.pumpAndSettle(); + expect(find.textContaining('Mar 8, 2026 – Mar 9, 2026'), findsOneWidget); + expect(find.textContaining('Mar 10, 2026'), findsNothing); + expect(find.text('Event link'), findsOneWidget); + expect(find.text('Notes attachment'), findsOneWidget); + expect(find.text('https://example.com/agenda'), findsOneWidget); + expect(find.text('Join meeting'), findsNothing); + expect(find.byTooltip('Edit Event'), findsNothing); + }); + testWidgets('invitation RSVP returns the selected response action', ( tester, ) async { @@ -2444,6 +2564,52 @@ void main() { expect(find.text('Categories: Blue category, Work'), findsOneWidget); }); + testWidgets( + 'Google status detail exposes provider type and actual settings', + (tester) async { + final event = CalendarScheduleItem( + id: 'event:status', + accountId: 'google:g', + provider: BusyProvider.google, + sourceId: 'calendar:primary', + providerCalendarId: 'primary', + title: 'Focus', + allDay: false, + start: DateTime(2026, 1, 15, 9), + end: DateTime(2026, 1, 15, 10), + eventType: 'focusTime', + googleStatusProperties: const { + 'autoDeclineMode': 'declineOnlyNewConflictingInvitations', + 'chatStatus': 'doNotDisturb', + }, + ); + await tester.pumpWidget( + localizedTestApp( + child: Scaffold( + body: Builder( + builder: (context) => TextButton( + onPressed: () => showScheduleItemDetailsPopover( + context: context, + anchorContext: context, + item: event, + ), + child: const Text('Open details'), + ), + ), + ), + ), + ); + await tester.tap(find.text('Open details')); + await tester.pumpAndSettle(); + expect(find.text('Focus time'), findsOneWidget); + expect( + find.text('Decline overlapping invitations: Decline new invitations'), + findsOneWidget, + ); + expect(find.text('Chat status: Do not disturb'), findsOneWidget); + }, + ); + testWidgets('event reminder details use locale-aware labels', (tester) async { final event = CalendarScheduleItem( id: 'event:localized-reminders', diff --git a/test/features/schedule/presentation/schedule_workspace_navigation_test.dart b/test/features/schedule/presentation/schedule_workspace_navigation_test.dart index add45582..4b49246c 100644 --- a/test/features/schedule/presentation/schedule_workspace_navigation_test.dart +++ b/test/features/schedule/presentation/schedule_workspace_navigation_test.dart @@ -11,6 +11,7 @@ import 'package:busymax/src/features/schedule/presentation/schedule_workspace.da 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/providers/busy_provider.dart'; +import 'package:busymax/src/schedule/schedule_repository.dart'; import 'package:busymax/src/schedule/schedule_scope.dart'; import 'package:drift/drift.dart'; import 'package:flutter/material.dart'; @@ -300,6 +301,14 @@ Future _pumpWorkspace( ProviderScope( overrides: [ databaseProvider.overrideWithValue(database), + // Navigation rendering uses the real cached projection; cloud range + // retrieval is exercised separately by the sync-engine tests. + scheduleRepositoryProvider.overrideWith( + (ref) => ScheduleRepository( + database, + ensureCloudCoverage: (_, _) async => true, + ), + ), accountsStreamProvider.overrideWith((ref) => Stream.value([account])), activeAccountProvider.overrideWithValue('account'), localTimeZoneProvider.overrideWithValue('America/Vancouver'), diff --git a/test/features/schedule/schedule_search_test.dart b/test/features/schedule/schedule_search_test.dart index ee4af73e..3e7b0f79 100644 --- a/test/features/schedule/schedule_search_test.dart +++ b/test/features/schedule/schedule_search_test.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:convert'; import 'package:busymax/src/schedule/schedule_search_match.dart'; import 'package:busymax/src/schedule/schedule_search_criteria.dart'; @@ -462,13 +463,22 @@ void main() { addTearDown(db.close); await _seedSearchDatabase(db); var calls = 0; + var cloudCalls = 0; final repo = ScheduleRepository( db, + ensureCloudCoverage: (_, filters) async { + cloudCalls++; + expect(filters.sourceIds, isEmpty); + return false; + }, ensureProjectionCoverage: (_) async { calls++; }, ); final range = ScheduleRange.day(DateTime(2026, 1, 1)); + final coverageFinished = repo.watchChanges().firstWhere( + (_) => repo.cloudCoverageCompleteFor(range) == false, + ); expect( await repo.listItems( range: range, @@ -476,7 +486,10 @@ void main() { ), isEmpty, ); + await coverageFinished; expect(calls, 1); + expect(cloudCalls, 1); + expect(repo.cloudCoverageCompleteFor(range), isFalse); expect( await repo.listItems( range: range, @@ -488,6 +501,7 @@ void main() { hasLength(1), ); expect(calls, 1); + expect(cloudCalls, 1); expect( await repo.listItems( range: ScheduleRange.day(DateTime(2026, 2, 15)), @@ -500,6 +514,93 @@ void main() { ); }, ); + test('range completion is keyed to requested search sources', () async { + final db = AppDatabase.memoryForTests(); + addTearDown(db.close); + ScheduleFilters? requested; + final repo = ScheduleRepository( + db, + ensureCloudCoverage: (_, filters) async { + requested = filters; + return true; + }, + ); + final range = ScheduleRange.day(DateTime(2020, 1, 10)); + const filters = ScheduleFilters( + accountIds: {'account-a'}, + sourceIds: {'unselected-source'}, + sourceFilterActive: true, + ); + final coverageFinished = repo.watchChanges().firstWhere( + (_) => repo.cloudCoverageCompleteFor(range, filters: filters) == true, + ); + await repo.listItems(range: range, filters: filters); + await coverageFinished; + expect(requested?.accountIds, {'account-a'}); + expect(requested?.sourceIds, {'unselected-source'}); + expect(repo.cloudCoverageCompleteFor(range, filters: filters), isTrue); + expect( + repo.cloudCoverageCompleteFor( + range, + filters: const ScheduleFilters( + accountIds: {'account-b'}, + sourceIds: {'another-source'}, + sourceFilterActive: true, + ), + ), + isNull, + ); + }); + test('cached items return while scoped cloud coverage is loading', () async { + final db = AppDatabase.memoryForTests(); + addTearDown(db.close); + await _seedSearchDatabase(db); + final release = Completer(); + final repo = ScheduleRepository( + db, + ensureCloudCoverage: (_, _) => release.future, + ); + final range = ScheduleRange.day(DateTime(2026, 2, 15)); + final cached = await repo + .listItems(range: range) + .timeout(const Duration(milliseconds: 100)); + expect(cached.map((item) => item.title), ['Future budget review']); + expect(repo.cloudCoverageCompleteFor(range), isNull); + final finished = repo.watchChanges().firstWhere( + (_) => repo.cloudCoverageCompleteFor(range) == true, + ); + release.complete(true); + await finished; + expect(repo.cloudCoverageCompleteFor(range), isTrue); + }); + test( + 'failed cloud coverage retries on reconnect without a five-minute wait', + () async { + final db = AppDatabase.memoryForTests(); + addTearDown(db.close); + var calls = 0; + var now = DateTime.utc(2026, 6, 12); + final repo = ScheduleRepository( + db, + ensureCloudCoverage: (_, _) async => ++calls > 1, + nowUtc: () => now, + ); + final range = ScheduleRange.day(DateTime(2020, 1, 10)); + final first = repo.watchChanges().firstWhere( + (_) => repo.cloudCoverageCompleteFor(range) == false, + ); + await repo.listItems(range: range); + await first; + expect(calls, 1); + now = now.add(const Duration(seconds: 11)); + final second = repo.watchChanges().firstWhere( + (_) => repo.cloudCoverageCompleteFor(range) == true, + ); + await repo.listItems(range: range); + await second.timeout(const Duration(seconds: 1)); + expect(calls, 2); + }, + ); test( 'repository date filter includes spanning events and excludes boundary and outside events', () async { @@ -1273,6 +1374,52 @@ void main() { expect(dueDayItems, isEmpty); }); + test( + 'Google task projection retains supplied source and task-page links', + () async { + final database = AppDatabase(NativeDatabase.memory()); + addTearDown(database.close); + await _insertScheduleAccount(database, provider: BusyProvider.google); + await _insertTaskList(database); + await database + .into(database.tasks) + .insert( + TasksCompanion.insert( + accountId: 'account', + taskListId: 'inbox', + id: 'assigned-task', + title: 'Review document', + status: const Value('needsAction'), + dueUtc: const Value('2026-06-12T00:00:00Z'), + assignmentInfoJson: const Value( + '{"surfaceType":"DOCUMENT","linkToTask":"https://docs.google.com/document/d/example"}', + ), + linksJson: const Value( + '[{"description":"Related brief","link":"https://example.test/brief"}]', + ), + webViewLink: const Value('https://tasks.google.com/task/example'), + rawJson: '{}', + createdLocalAtUtc: _now, + updatedLocalAtUtc: _now, + ), + ); + final items = await ScheduleRepository(database).listItems( + range: ScheduleRange.day(DateTime(2026, 6, 12)), + filters: const ScheduleFilters( + accountIds: {'account'}, + includeCalendarEvents: false, + ), + ); + final task = items.single as TaskScheduleItem; + expect(task.isAssigned, isTrue); + expect(task.availableSourceLinks.map((link) => link.url), [ + 'https://docs.google.com/document/d/example', + 'https://example.test/brief', + 'https://tasks.google.com/task/example', + ]); + }, + ); + test('Microsoft task with midnight due appears as timed slot', () async { final database = AppDatabase(NativeDatabase.memory()); addTearDown(database.close); diff --git a/test/features/settings/presentation/settings_screen_test.dart b/test/features/settings/presentation/settings_screen_test.dart index 647bcdfd..ba3af254 100644 --- a/test/features/settings/presentation/settings_screen_test.dart +++ b/test/features/settings/presentation/settings_screen_test.dart @@ -24,6 +24,7 @@ import 'package:busymax/src/features/accounts/data/accounts_repository.dart'; import 'package:busymax/src/features/accounts/domain/account_connection_state.dart'; import 'package:busymax/src/features/auth/data/auth_repository.dart'; import 'package:busymax/src/features/calendar/data/calendar_repository.dart'; +import 'package:busymax/src/google_calendar/google_calendar_api_client.dart'; import 'package:busymax/src/features/settings/presentation/settings_screen.dart'; import 'package:busymax/src/features/task_lists/data/task_lists_repository.dart'; import 'package:busymax/src/features/sync/sync_auth_error.dart'; @@ -428,6 +429,58 @@ void main() { ); }); + testWidgets('Linux sharing entry opens owner ACL permissions', ( + tester, + ) async { + var permissionReads = 0; + final sharingClient = GoogleCalendarApiClient( + httpClient: MockClient((request) async { + if (request.url.path.endsWith('/acl')) { + permissionReads++; + return http.Response(jsonEncode({'items': []}), 200); + } + return http.Response('unexpected request', 404); + }), + baseUri: Uri.parse('https://www.googleapis.com'), + ); + final container = _container( + selectedAccountId: 'google:g', + authRepository: _FakeAuthRepository(), + accounts: const [_googleAccount], + googleSharingClient: sharingClient, + calendarSources: const [ + CalendarSourceEntity( + id: 'owned', + accountId: 'google:g', + provider: BusyProvider.google, + providerCalendarId: 'owned@example.com', + summary: 'Owned', + selected: true, + hidden: false, + readOnly: false, + isDeleted: false, + accessRole: 'owner', + ), + ], + ); + addTearDown(container.dispose); + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: localizedTestApp( + child: const SettingsScreen(initialPage: SettingsPage.accounts), + ), + ), + ); + await tester.pumpAndSettle(); + final entry = find.text('Manage calendar sharing'); + await tester.ensureVisible(entry); + await tester.tap(entry); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('calendar-sharing-recipient')), findsOneWidget); + expect(permissionReads, 1); + }); + testWidgets('Every Settings page uses only its headerbar title', ( tester, ) async { @@ -1985,6 +2038,7 @@ ProviderContainer _container({ DavAccountOnboardingService? davOnboardingService, List davCollections = const [], List calendarSources = const [], + GoogleCalendarApiClient? googleSharingClient, List taskLists = const [], DesktopAutostartService? autostartService, LocalSettingsStore? settingsStore, @@ -2015,6 +2069,10 @@ ProviderContainer _container({ calendarSourcesStreamProvider.overrideWith( (ref) => Stream.value(calendarSources), ), + if (googleSharingClient != null) + googleCalendarApiClientForAccountProvider.overrideWith( + (ref, accountId) => googleSharingClient, + ), scheduleTaskListsProvider.overrideWith((ref) async => taskLists), davConflictsStreamProvider.overrideWith((ref) => Stream.value(const [])), webCalSubscriptionsProvider.overrideWith((ref) => Stream.value(const [])), diff --git a/test/features/sync/calendar_event_clear_patch_test.dart b/test/features/sync/calendar_event_clear_patch_test.dart index 388887ca..489d3fab 100644 --- a/test/features/sync/calendar_event_clear_patch_test.dart +++ b/test/features/sync/calendar_event_clear_patch_test.dart @@ -67,6 +67,102 @@ void main() { }, ); } + + test( + 'Microsoft plain edit replays new body and retains Teams link', + () async { + final result = await _editAndReplay( + provider: BusyProvider.microsoft, + editedDescription: 'Updated agenda', + onlineMeeting: true, + ); + final body = (jsonDecode(result.patchRequest.body) as Map) + .cast(); + final outgoing = (body['body'] as Map).cast(); + expect(outgoing['contentType'], 'html'); + expect(outgoing['content'], contains('Updated agenda')); + expect( + outgoing['content'], + contains('https://teams.microsoft.com/l/meetup-join/example'), + ); + expect(outgoing['content'], isNot(contains('Original agenda'))); + expect(result.queuedRequest, contains('descriptionHtml')); + }, + ); + + test('Microsoft unrelated edit does not replace HTML body', () async { + final result = await _editAndReplay( + provider: BusyProvider.microsoft, + onlineMeeting: true, + ); + final body = (jsonDecode(result.patchRequest.body) as Map) + .cast(); + expect(body, isNot(contains('body'))); + }); + + test('Microsoft formatting-only edit reaches provider', () async { + final result = await _editAndReplay( + provider: BusyProvider.microsoft, + onlineMeeting: true, + editedDescriptionHtml: '

Original agenda

', + ); + final body = (jsonDecode(result.patchRequest.body) as Map) + .cast(); + final outgoing = (body['body'] as Map).cast(); + expect(outgoing['content'], contains('Original agenda')); + expect(outgoing['content'], contains('Join Teams meeting')); + }); + + test('Microsoft explicit clear retains Teams meeting information', () async { + final result = await _editAndReplay( + provider: BusyProvider.microsoft, + onlineMeeting: true, + clearDescription: true, + ); + final body = (jsonDecode(result.patchRequest.body) as Map) + .cast(); + final outgoing = (body['body'] as Map).cast(); + expect(outgoing['content'], isNot(contains('Original agenda'))); + expect(outgoing['content'], contains('Join Teams meeting')); + }); + + test( + 'nested Teams HTML is replayed once without stale trailing notes', + () async { + const meeting = + '

Meeting ID: 123

'; + const original = + '

Old before

$meeting

Old after

'; + final first = await _editAndReplay( + provider: BusyProvider.microsoft, + onlineMeeting: true, + onlineBodyHtml: original, + editedDescription: 'First revision', + ); + final firstHtml = + ((jsonDecode(first.patchRequest.body) as Map)['body'] + as Map)['content'] + as String; + expect(firstHtml, contains(meeting)); + expect(firstHtml, isNot(contains('Old before'))); + expect(firstHtml, isNot(contains('Old after'))); + expect(first.reopenedDescription, contains('First revision')); + final second = await _editAndReplay( + provider: BusyProvider.microsoft, + onlineMeeting: true, + onlineBodyHtml: firstHtml, + editedDescription: 'Second revision', + ); + final secondHtml = + ((jsonDecode(second.patchRequest.body) as Map)['body'] + as Map)['content'] + as String; + expect(meeting.allMatches(secondHtml), hasLength(1)); + expect(secondHtml, isNot(contains('First revision'))); + expect(secondHtml, contains('Second revision')); + expect(second.reopenedDescription, contains('Second revision')); + }, + ); } void _expectUnrelatedOptionalFieldsOmitted(Map body) { @@ -79,12 +175,22 @@ void _expectUnrelatedOptionalFieldsOmitted(Map body) { } Future< - ({int applied, Map queuedRequest, http.Request patchRequest}) + ({ + int applied, + Map queuedRequest, + http.Request patchRequest, + String? reopenedDescription, + }) > _editAndReplay({ required BusyProvider provider, bool clearRecurrence = false, bool clearAttendees = false, + String? editedDescription, + String? editedDescriptionHtml, + bool clearDescription = false, + bool onlineMeeting = false, + String? onlineBodyHtml, }) async { final database = AppDatabase(NativeDatabase.memory()); addTearDown(database.close); @@ -154,16 +260,31 @@ _editAndReplay({ endTimeZone: 'UTC', recurrenceJson: recurrence, attendeesJson: attendees, + description: onlineMeeting ? 'Original agenda' : null, + conferenceJson: onlineMeeting + ? const { + 'joinUrl': 'https://teams.microsoft.com/l/meetup-join/example', + } + : null, organizerJson: provider == BusyProvider.google ? const {'self': true} : null, updatedAtServer: '2026-06-08T00:00:00.000Z', - rawJson: _eventJson( - provider, - edited: false, - includeRecurrence: true, - includeAttendees: true, - ), + rawJson: { + ..._eventJson( + provider, + edited: false, + includeRecurrence: true, + includeAttendees: true, + ), + if (onlineMeeting) + 'body': { + 'contentType': 'html', + 'content': + onlineBodyHtml ?? + '

Original agenda

', + }, + }, ), ); final eventId = CalendarRepository.eventId( @@ -172,29 +293,15 @@ _editAndReplay({ providerCalendarId: 'cal-1', providerEventId: 'event-1', ); - final originalDraft = EventEditorDraft.existing( - eventId: eventId, - accountId: 'account', - sourceId: CalendarRepository.sourceId( - accountId: 'account', - provider: provider, - providerCalendarId: 'cal-1', - ), - providerCalendarId: 'cal-1', - title: 'Planning', - allDay: false, - start: DateTime.utc(2026, 6, 8, 9), - end: DateTime.utc(2026, 6, 8, 10), - startTimeZone: 'UTC', - endTimeZone: 'UTC', - recurrence: recurrence, - attendees: const [ - EventAttendeeDraft(email: 'guest@example.com', displayName: 'Guest'), - ], + final originalDraft = EventEditorDraft.fromEventDetail( + (await repository.loadEventDetail(eventId))!, ); await repository.updateLocalEvent( originalDraft.copyWith( title: 'Edited planning', + description: editedDescription, + descriptionHtml: editedDescriptionHtml, + clearDescription: clearDescription, clearRecurrence: clearRecurrence, attendees: clearAttendees ? const [] : null, ), @@ -208,15 +315,30 @@ _editAndReplay({ if (request.method == 'PATCH') { patchRequest = request; } + final response = _eventJson( + provider, + edited: request.method == 'PATCH', + includeRecurrence: request.method != 'PATCH' || !clearRecurrence, + includeAttendees: request.method != 'PATCH' || !clearAttendees, + ); + if (provider == BusyProvider.microsoft && onlineMeeting) { + response['onlineMeeting'] = const { + 'joinUrl': 'https://teams.microsoft.com/l/meetup-join/example', + }; + response['isOnlineMeeting'] = true; + response['body'] = + request.method == 'PATCH' && + (jsonDecode(request.body) as Map).containsKey('body') + ? (jsonDecode(request.body) as Map)['body'] + : { + 'contentType': 'html', + 'content': + onlineBodyHtml ?? + '

Original agenda

', + }; + } return http.Response( - jsonEncode( - _eventJson( - provider, - edited: request.method == 'PATCH', - includeRecurrence: request.method != 'PATCH' || !clearRecurrence, - includeAttendees: request.method != 'PATCH' || !clearAttendees, - ), - ), + jsonEncode(response), 200, headers: {'Content-Type': 'application/json'}, ); @@ -245,6 +367,9 @@ _editAndReplay({ applied: applied, queuedRequest: queuedRequest, patchRequest: patchRequest, + reopenedDescription: (await repository.loadEventDetail( + eventId, + ))?.description, ); } diff --git a/test/features/sync/calendar_pending_ops_replayer_test.dart b/test/features/sync/calendar_pending_ops_replayer_test.dart index dde8bb86..ae8c88b5 100644 --- a/test/features/sync/calendar_pending_ops_replayer_test.dart +++ b/test/features/sync/calendar_pending_ops_replayer_test.dart @@ -17,6 +17,7 @@ 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/calendar/domain/event_timing_policy.dart'; +import 'package:busymax/src/features/calendar/domain/google_status_event.dart'; import 'package:busymax/src/features/sync/calendar_pending_ops_replayer.dart'; import 'package:busymax/src/features/sync/calendar_sync_engine.dart'; import 'package:busymax/src/features/sync/pending_op_resolution_service.dart'; @@ -25,12 +26,16 @@ import 'package:busymax/src/features/maps/domain/location_result.dart'; import 'package:busymax/src/features/maps/data/location_resolution_repository.dart'; import 'package:busymax/src/features/maps/application/location_destination_resolver.dart'; import 'package:busymax/src/google_calendar/google_calendar_errors.dart'; +import 'package:busymax/src/google_calendar/google_calendar_api_client.dart'; +import 'package:busymax/src/google_calendar/google_calendar_mapper.dart'; import 'package:busymax/src/ical/ical_import_service.dart'; import 'package:busymax/src/microsoft_calendar/microsoft_calendar_errors.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_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; import '../../support/memory_settings_store.dart'; import '../../support/process_time_zone.dart'; @@ -1563,7 +1568,7 @@ END:VEVENT client.createEventOverride = (calendarId, mutation) => CalendarEventDto( provider: BusyProvider.google, providerCalendarId: calendarId, - providerEventId: mutation.providerEventId!, + providerEventId: mutation.providerEventId ?? 'imported-master', title: mutation.title!, location: mutation.location, startDateTime: mutation.startDateTime, @@ -1572,7 +1577,7 @@ END:VEVENT endTimeZone: mutation.endTimeZone, recurrenceJson: mutation.recurrence, rawJson: { - 'id': mutation.providerEventId, + 'id': mutation.providerEventId ?? 'imported-master', 'summary': mutation.title, 'location': mutation.location, 'start': { @@ -1667,7 +1672,7 @@ END:VEVENT (event) => event.providerRecurringEventId == master.providerEventId, ) .toList(); - expect(instances, hasLength(1)); + expect(instances, hasLength(1), reason: events.toString()); expect( instances, everyElement( @@ -1677,7 +1682,11 @@ END:VEVENT final rememberedBeforeResolution = await database .select(database.locationResolutions) .get(); - expect(rememberedBeforeResolution, hasLength(1)); + expect( + rememberedBeforeResolution, + hasLength(1), + reason: 'calls=${client.calls}, events=$events, master=$master', + ); expect( rememberedBeforeResolution.single.kind, googleSeriesLocationResolutionKind, @@ -2427,6 +2436,666 @@ END:VEVENT expect(client.ifMatches, ['"checked"']); }); + test( + 'Google label selection reaches replayed HTTP body and local detail', + () async { + final repository = CalendarRepository(database: database); + final eventId = await _insertEvent( + database, + providerEventId: 'label-event', + ); + final detail = (await repository.loadEventDetail(eventId))!; + await repository.updateLocalEvent( + EventEditorDraft.fromEventDetail( + detail, + ).copyWith(eventLabelId: 'label-1'), + ); + final requests = []; + final api = GoogleCalendarApiClient( + httpClient: MockClient((request) async { + requests.add(request); + final event = { + 'id': 'label-event', + 'summary': 'Base', + 'etag': request.method == 'PATCH' ? '"v2"' : '"v1"', + 'eventLabelId': request.method == 'PATCH' ? 'label-1' : 'old-label', + 'start': {'dateTime': '2026-06-08T09:00:00.000Z'}, + 'end': {'dateTime': '2026-06-08T10:00:00.000Z'}, + 'organizer': {'self': true}, + 'updated': '2026-06-08T00:00:00.000Z', + }; + return http.Response(jsonEncode(event), 200); + }), + baseUri: Uri.parse('https://www.googleapis.com'), + ); + expect( + await CalendarPendingOpsReplayer( + database: database, + client: api, + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 6, 8), + ).replayDueOps(), + 1, + ); + final patch = requests.singleWhere( + (request) => request.method == 'PATCH', + ); + expect(patch.url.queryParameters['eventLabelVersion'], '1'); + expect(jsonDecode(patch.body)['eventLabelId'], 'label-1'); + expect( + (await repository.loadEventDetail(eventId))!.raw, + containsPair('eventLabelId', 'label-1'), + ); + }, + ); + + test( + 'existing Gmail color and reminders replay as targeted PATCHes', + () async { + final repository = CalendarRepository(database: database); + await repository.upsertEvent( + accountId: 'account', + event: const CalendarEventDto( + provider: BusyProvider.google, + providerCalendarId: 'cal-1', + providerEventId: 'gmail-existing', + eventType: 'fromGmail', + title: 'Flight', + colorId: '1', + startDateTime: '2026-06-08T09:00:00Z', + endDateTime: '2026-06-08T10:00:00Z', + remindersJson: {'useDefault': true}, + organizerJson: {'self': true}, + etagOrChangeKey: '"v1"', + rawJson: { + 'id': 'gmail-existing', + 'summary': 'Flight', + 'eventType': 'fromGmail', + 'colorId': '1', + 'reminders': {'useDefault': true}, + 'organizer': {'self': true}, + 'etag': '"v1"', + }, + ), + ); + final id = CalendarRepository.eventId( + accountId: 'account', + provider: BusyProvider.google, + providerCalendarId: 'cal-1', + providerEventId: 'gmail-existing', + ); + var remoteColor = '1'; + Map remoteReminders = {'useDefault': true}; + var version = 1; + final patches = >[]; + final api = GoogleCalendarApiClient( + httpClient: MockClient((request) async { + if (request.method == 'PATCH') { + final body = (jsonDecode(request.body) as Map) + .cast(); + patches.add(body); + if (body['colorId'] case final String value) remoteColor = value; + if (body['reminders'] case final Map value) { + remoteReminders = value.cast(); + } + version++; + } + return http.Response( + jsonEncode({ + 'id': 'gmail-existing', + 'summary': 'Flight', + 'eventType': 'fromGmail', + 'colorId': remoteColor, + 'reminders': remoteReminders, + 'start': {'dateTime': '2026-06-08T09:00:00Z'}, + 'end': {'dateTime': '2026-06-08T10:00:00Z'}, + 'organizer': {'self': true}, + 'etag': '"v$version"', + }), + 200, + ); + }), + baseUri: Uri.parse('https://www.googleapis.com'), + ); + Future replay() async { + expect( + await CalendarPendingOpsReplayer( + database: database, + client: api, + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 6, 8), + ).replayDueOps(), + 1, + ); + } + + await repository.updateLocalEvent( + EventEditorDraft.fromEventDetail( + (await repository.loadEventDetail(id))!, + ).copyWith(colorId: '2'), + ); + await replay(); + expect(patches.single, {'colorId': '2'}); + expect((await repository.loadEventDetail(id))!.eventType, 'fromGmail'); + await repository.updateLocalEvent( + EventEditorDraft.fromEventDetail( + (await repository.loadEventDetail(id))!, + ).copyWith( + reminders: const { + 'useDefault': false, + 'overrides': [ + {'method': 'popup', 'minutes': 10}, + ], + }, + remindersChanged: true, + ), + ); + await replay(); + expect(patches.last.keys, {'reminders'}); + expect((patches.last['reminders'] as Map)['useDefault'], false); + expect((await repository.loadEventDetail(id))!.eventType, 'fromGmail'); + }, + ); + + for (final type in googleStatusEventTypes) { + test('existing Google $type settings survive queued HTTP replay', () async { + final repository = CalendarRepository(database: database); + await repository.upsertSource( + accountId: 'account', + source: const CalendarSourceDto( + provider: BusyProvider.google, + providerCalendarId: 'cal-1', + summary: 'Primary', + primaryCalendar: true, + ), + ); + final key = googleStatusPropertiesKey(type)!; + final originalProperties = defaultGoogleStatusProperties(type); + final editedProperties = type == 'workingLocation' + ? { + 'type': 'customLocation', + 'customLocation': {'label': 'Library'}, + } + : { + ...originalProperties, + 'autoDeclineMode': 'declineAllConflictingInvitations', + }; + var remote = { + 'id': 'status-existing', + 'summary': 'Status', + 'eventType': type, + key: originalProperties, + 'start': {'dateTime': '2026-06-08T09:00:00Z', 'timeZone': 'UTC'}, + 'end': {'dateTime': '2026-06-08T10:00:00Z', 'timeZone': 'UTC'}, + 'visibility': type == 'workingLocation' ? 'public' : 'default', + 'transparency': type == 'workingLocation' ? 'transparent' : 'opaque', + 'organizer': {'self': true}, + 'updated': '2026-06-08T00:00:00Z', + 'etag': '"v1"', + }; + await repository.upsertEvent( + accountId: 'account', + event: googleCalendarEventFromJson('cal-1', remote), + ); + final id = CalendarRepository.eventId( + accountId: 'account', + provider: BusyProvider.google, + providerCalendarId: 'cal-1', + providerEventId: 'status-existing', + ); + await repository.updateLocalEvent( + EventEditorDraft.fromEventDetail( + (await repository.loadEventDetail(id))!, + ).copyWith(googleStatusProperties: editedProperties), + ); + final patches = >[]; + final api = GoogleCalendarApiClient( + httpClient: MockClient((request) async { + if (request.method == 'PATCH') { + final patch = Map.from( + jsonDecode(request.body) as Map, + ); + patches.add(patch); + remote = {...remote, ...patch, 'updated': '2026-06-08T01:00:00Z'}; + } + return http.Response(jsonEncode(remote), 200); + }), + baseUri: Uri.parse('https://www.googleapis.com'), + ); + expect( + await CalendarPendingOpsReplayer( + database: database, + client: api, + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 6, 9), + ).replayDueOps(), + 1, + ); + expect(patches, hasLength(1)); + expect(patches.single[key], editedProperties); + expect(patches.single, isNot(contains('eventType'))); + expect( + EventEditorDraft.fromEventDetail( + (await repository.loadEventDetail(id))!, + ).googleStatusProperties, + editedProperties, + ); + }); + } + + test('newer remote Google label blocks queued label replacement', () async { + final repository = CalendarRepository(database: database); + final baseline = { + 'id': 'label-conflict', + 'summary': 'Base', + 'eventLabelId': 'label-a', + 'start': {'dateTime': '2026-06-08T09:00:00Z'}, + 'end': {'dateTime': '2026-06-08T10:00:00Z'}, + 'organizer': {'self': true}, + 'updated': '2026-06-08T00:00:00Z', + 'etag': '"v1"', + }; + await repository.upsertEvent( + accountId: 'account', + event: googleCalendarEventFromJson('cal-1', baseline), + ); + final id = CalendarRepository.eventId( + accountId: 'account', + provider: BusyProvider.google, + providerCalendarId: 'cal-1', + providerEventId: 'label-conflict', + ); + await repository.updateLocalEvent( + EventEditorDraft.fromEventDetail( + (await repository.loadEventDetail(id))!, + ).copyWith(eventLabelId: 'label-b'), + ); + final requests = []; + final api = GoogleCalendarApiClient( + httpClient: MockClient((request) async { + requests.add(request); + return http.Response( + jsonEncode({ + ...baseline, + 'eventLabelId': 'label-c', + 'updated': '2026-06-08T01:00:00Z', + 'etag': '"v2"', + }), + 200, + ); + }), + baseUri: Uri.parse('https://www.googleapis.com'), + ); + await CalendarPendingOpsReplayer( + database: database, + client: api, + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 6, 9), + ).replayDueOps(); + expect(requests.where((request) => request.method == 'PATCH'), isEmpty); + expect( + (await database.select(database.pendingOps).get()).single.lastErrorCode, + 'conflict', + ); + }); + + test('newer remote Google label blocks queued explicit removal', () async { + final eventId = await _insertEvent( + database, + providerEventId: 'label-clear-conflict', + ); + final baseline = { + 'id': 'label-clear-conflict', + 'summary': 'Base', + 'eventLabelId': 'label-a', + 'updated': '2026-06-08T00:00:00Z', + }; + await _enqueueEventOp( + database, + id: 'clear-label', + operation: 'patch', + operationType: 'event.patch', + eventId: eventId, + request: {'eventLabelId': ''}, + baselineRawJson: jsonEncode(baseline), + ); + final requests = []; + final api = GoogleCalendarApiClient( + httpClient: MockClient((request) async { + requests.add(request); + return http.Response( + jsonEncode({ + ...baseline, + 'eventLabelId': 'label-c', + 'updated': '2026-06-08T01:00:00Z', + }), + 200, + ); + }), + baseUri: Uri.parse('https://www.googleapis.com'), + ); + await CalendarPendingOpsReplayer( + database: database, + client: api, + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 6, 9), + ).replayDueOps(); + expect(requests.where((request) => request.method == 'PATCH'), isEmpty); + expect( + (await database.select(database.pendingOps).get()).single.lastErrorCode, + 'conflict', + ); + }); + + test('remote Google status-setting change blocks queued settings', () async { + final eventId = await _insertEvent( + database, + providerEventId: 'status-conflict', + ); + final baseline = { + 'id': 'status-conflict', + 'summary': 'Base', + 'eventType': 'focusTime', + 'focusTimeProperties': { + 'autoDeclineMode': 'declineNone', + 'chatStatus': 'available', + }, + 'updated': '2026-06-08T00:00:00Z', + }; + await _enqueueEventOp( + database, + id: 'status-conflict-op', + operation: 'patch', + operationType: 'event.patch', + eventId: eventId, + request: { + 'googleStatusProperties': { + 'autoDeclineMode': 'declineAllConflictingInvitations', + 'chatStatus': 'available', + }, + calendarEventGoogleStatusTypeContextKey: 'focusTime', + }, + baselineRawJson: jsonEncode(baseline), + ); + final requests = []; + final api = GoogleCalendarApiClient( + httpClient: MockClient((request) async { + requests.add(request); + return http.Response( + jsonEncode({ + ...baseline, + 'focusTimeProperties': { + 'autoDeclineMode': 'declineNone', + 'chatStatus': 'doNotDisturb', + }, + 'updated': '2026-06-08T01:00:00Z', + }), + 200, + ); + }), + baseUri: Uri.parse('https://www.googleapis.com'), + ); + await CalendarPendingOpsReplayer( + database: database, + client: api, + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 6, 9), + ).replayDueOps(); + expect(requests.where((request) => request.method == 'PATCH'), isEmpty); + expect( + (await database.select(database.pendingOps).get()).single.lastErrorCode, + 'conflict', + ); + }); + + test('unrelated remote Google change permits status edit', () async { + final eventId = await _insertEvent( + database, + providerEventId: 'status-unrelated', + ); + final baseline = { + 'id': 'status-unrelated', + 'summary': 'Base', + 'eventType': 'focusTime', + 'focusTimeProperties': { + 'chatStatus': 'available', + 'autoDeclineMode': 'declineNone', + }, + 'updated': '2026-06-08T00:00:00Z', + }; + await _enqueueEventOp( + database, + id: 'status-unrelated-op', + operation: 'patch', + operationType: 'event.patch', + eventId: eventId, + request: { + 'googleStatusProperties': { + 'chatStatus': 'doNotDisturb', + 'autoDeclineMode': 'declineNone', + }, + calendarEventGoogleStatusTypeContextKey: 'focusTime', + }, + baselineRawJson: jsonEncode(baseline), + ); + final requests = []; + final api = GoogleCalendarApiClient( + httpClient: MockClient((request) async { + requests.add(request); + return http.Response( + jsonEncode({ + ...baseline, + 'summary': 'Remote title', + 'focusTimeProperties': { + 'autoDeclineMode': 'declineNone', + 'chatStatus': 'available', + }, + 'updated': '2026-06-08T01:00:00Z', + }), + 200, + ); + }), + baseUri: Uri.parse('https://www.googleapis.com'), + ); + expect( + await CalendarPendingOpsReplayer( + database: database, + client: api, + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 6, 9), + ).replayDueOps(), + 1, + ); + expect( + requests.where((request) => request.method == 'PATCH'), + hasLength(1), + ); + }); + + test( + 'dependent Google status edit rebases then detects newer remote change', + () async { + final repository = CalendarRepository(database: database); + await repository.upsertSource( + accountId: 'account', + source: const CalendarSourceDto( + provider: BusyProvider.google, + providerCalendarId: 'cal-1', + summary: 'Primary', + primaryCalendar: true, + ), + ); + var remote = { + 'id': 'status-dependent', + 'summary': 'Focus', + 'eventType': 'focusTime', + 'focusTimeProperties': { + 'autoDeclineMode': 'declineNone', + 'chatStatus': 'available', + }, + 'start': {'dateTime': '2026-06-08T09:00:00Z', 'timeZone': 'UTC'}, + 'end': {'dateTime': '2026-06-08T10:00:00Z', 'timeZone': 'UTC'}, + 'transparency': 'opaque', + 'organizer': {'self': true}, + 'updated': '2026-06-08T00:00:00Z', + 'etag': '"v1"', + }; + await repository.upsertEvent( + accountId: 'account', + event: googleCalendarEventFromJson('cal-1', remote), + ); + final id = CalendarRepository.eventId( + accountId: 'account', + provider: BusyProvider.google, + providerCalendarId: 'cal-1', + providerEventId: 'status-dependent', + ); + await repository.updateLocalEvent( + EventEditorDraft.fromEventDetail( + (await repository.loadEventDetail(id))!, + ).copyWith( + googleStatusProperties: { + 'autoDeclineMode': 'declineNone', + 'chatStatus': 'doNotDisturb', + }, + ), + ); + await repository.updateLocalEvent( + EventEditorDraft.fromEventDetail( + (await repository.loadEventDetail(id))!, + ).copyWith( + googleStatusProperties: { + 'autoDeclineMode': 'declineAllConflictingInvitations', + 'chatStatus': 'doNotDisturb', + }, + ), + ); + var committedFirst = false; + final patches = >[]; + final api = GoogleCalendarApiClient( + httpClient: MockClient((request) async { + if (request.method == 'GET' && committedFirst) { + remote = { + ...remote, + 'focusTimeProperties': { + 'autoDeclineMode': 'declineNone', + 'chatStatus': 'available', + }, + 'updated': '2026-06-08T02:00:00Z', + 'etag': '"v3"', + }; + } + if (request.method == 'PATCH') { + final patch = Map.from( + jsonDecode(request.body) as Map, + ); + patches.add(patch); + remote = { + ...remote, + ...patch, + 'updated': '2026-06-08T01:00:00Z', + 'etag': '"v2"', + }; + committedFirst = true; + } + return http.Response(jsonEncode(remote), 200); + }), + baseUri: Uri.parse('https://www.googleapis.com'), + ); + await CalendarPendingOpsReplayer( + database: database, + client: api, + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 6, 9), + ).replayDueOps(); + expect(patches, hasLength(1)); + expect( + (await database.select(database.pendingOps).get()).single.lastErrorCode, + 'conflict', + ); + }, + ); + + test( + 'Google working location reaches replayed HTTP and reopened detail', + () async { + final repository = CalendarRepository(database: database); + await repository.upsertSource( + accountId: 'account', + source: const CalendarSourceDto( + provider: BusyProvider.google, + providerCalendarId: 'cal-1', + summary: 'Primary', + primaryCalendar: true, + ), + ); + await repository.createLocalEvent( + EventEditorDraft.newEvent( + accountId: 'account', + sourceId: 'account|google|cal-1', + providerCalendarId: 'cal-1', + start: DateTime.utc(2026, 6, 8, 9), + end: DateTime.utc(2026, 6, 8, 10), + ).copyWith( + title: 'Work site', + eventType: 'workingLocation', + googleStatusProperties: { + ...defaultGoogleStatusProperties('workingLocation'), + 'type': 'customLocation', + 'customLocation': {'label': 'Library'}, + }, + showAs: 'transparent', + visibilityOrSensitivity: 'public', + ), + ); + final requests = []; + final api = GoogleCalendarApiClient( + httpClient: MockClient((request) async { + requests.add(request); + if (request.method != 'POST') return http.Response('{}', 404); + final body = (jsonDecode(request.body) as Map) + .cast(); + return http.Response( + jsonEncode({ + ...body, + 'etag': '"v1"', + 'updated': '2026-06-08T09:00:00Z', + }), + 200, + ); + }), + baseUri: Uri.parse('https://www.googleapis.com'), + ); + expect( + await CalendarPendingOpsReplayer( + database: database, + client: api, + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 6, 8), + ).replayDueOps(), + 1, + ); + final posted = + jsonDecode( + requests + .singleWhere((request) => request.method == 'POST') + .body, + ) + as Map; + expect(posted['eventType'], 'workingLocation'); + expect((posted['workingLocationProperties'] as Map)['customLocation'], { + 'label': 'Library', + }); + expect(posted['visibility'], 'public'); + expect(posted['transparency'], 'transparent'); + final row = (await database.select(database.calendarEvents).get()).single; + expect( + (await repository.loadEventDetail(row.id))!.eventType, + 'workingLocation', + ); + }, + ); + test('zoned guarded series edit replays the same civil delta', () async { final repository = CalendarRepository(database: database); final id = await _insertGoogleOccurrence(repository, day: 8); @@ -2919,6 +3588,125 @@ END:VEVENT }, ); + test( + 'H imported Google exception uses targeted originalStart lookup', + () async { + final repository = CalendarRepository( + database: database, + now: () => DateTime.utc(2026, 8, 29), + ); + final importer = IcalImportService( + database: database, + calendarRepository: repository, + ); + final preview = importer.parsePreview( + utf8.encode( + _icalCalendar(''' +BEGIN:VEVENT +UID:replay-import-metadata +DTSTART:20260830T160000Z +DTEND:20260830T170000Z +SUMMARY:Master +RRULE:FREQ=DAILY;COUNT=2 +CLASS:PRIVATE +TRANSP:TRANSPARENT +END:VEVENT +BEGIN:VEVENT +UID:replay-import-metadata +RECURRENCE-ID:20260831T160000Z +DTSTART:20260831T180000Z +DTEND:20260831T190000Z +SUMMARY:Moved +CLASS:PRIVATE +TRANSP:OPAQUE +BEGIN:VALARM +ACTION:DISPLAY +TRIGGER:PT0M +END:VALARM +END:VEVENT +'''), + ), + ); + expect( + (await importer.importPreview( + preview: preview, + destination: (await importer.writableDestinations()).single, + )).queued, + 1, + ); + final remoteOccurrence = { + 'id': 'occurrence-1', + 'recurringEventId': 'imported-master', + 'originalStartTime': {'dateTime': '2026-08-31T16:00:00Z'}, + 'summary': 'Master', + 'start': {'dateTime': '2026-08-31T16:00:00Z'}, + 'end': {'dateTime': '2026-08-31T17:00:00Z'}, + }; + final patches = >[]; + final instanceQueries = []; + final realClient = GoogleCalendarApiClient( + httpClient: MockClient((request) async { + if (request.method == 'GET' && + request.url.path.endsWith('/instances')) { + instanceQueries.add(request.url); + if (request.url.queryParameters['originalStart'] != + '2026-08-31T16:00:00.000Z') { + return http.Response('Targeted originalStart required', 400); + } + return http.Response( + jsonEncode({ + 'items': [remoteOccurrence], + }), + 200, + ); + } + if (request.method == 'GET') { + return http.Response(jsonEncode({'items': []}), 200); + } + if (request.method == 'POST') { + return http.Response( + jsonEncode({ + 'id': 'imported-master', + 'summary': 'Master', + 'start': {'dateTime': '2026-08-30T16:00:00Z'}, + 'end': {'dateTime': '2026-08-30T17:00:00Z'}, + }), + 200, + ); + } + if (request.method == 'PATCH') { + final patch = (jsonDecode(request.body) as Map) + .cast(); + patches.add(patch); + remoteOccurrence.addAll(patch); + return http.Response(jsonEncode(remoteOccurrence), 200); + } + return http.Response('{}', 404); + }), + baseUri: Uri.parse('https://www.googleapis.com'), + ); + final replayer = CalendarPendingOpsReplayer( + database: database, + client: realClient, + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 10, 2), + ); + await replayer.replayDueOps(); + await replayer.replayDueOps(); + expect(instanceQueries, hasLength(1)); + expect(patches, hasLength(1)); + expect(patches.single['visibility'], 'private'); + expect(patches.single['transparency'], 'opaque'); + expect(patches.single['reminders'], { + 'useDefault': false, + 'overrides': [ + {'method': 'popup', 'minutes': 0}, + ], + }); + expect(await database.select(database.pendingOps).get(), isEmpty); + }, + ); + test( 'Google following title/time split copies the series point once across retry and sync', () async { @@ -5722,6 +6510,7 @@ class _FakeCalendarClient implements CloudCalendarClient, CompleteRecurringInstanceClient, + PrivateCalendarImportClient, CalendarListManagementClient { final calls = []; final createdMutations = []; @@ -5731,6 +6520,7 @@ class _FakeCalendarClient final ifMatches = []; final invitationResponses = []; final Map _createdEventsByIdentity = {}; + final Map _importedEventsByUid = {}; int _createdCount = 0; int transientUpdateFailures = 0; int? transientUpdateFailureCall; @@ -5825,6 +6615,40 @@ class _FakeCalendarClient return event; } + @override + Future> eventsWithICalUid({ + required String calendarId, + required String iCalUid, + }) async { + calls.add('eventsWithICalUid:$calendarId:$iCalUid'); + final existing = _importedEventsByUid[iCalUid]; + return existing == null ? const [] : [existing]; + } + + @override + Future importEvent({ + required String calendarId, + required String iCalUid, + required CalendarEventMutation mutation, + }) async { + calls.add('importEvent:$calendarId:$iCalUid'); + createdMutations.add(mutation); + final event = + createEventOverride?.call(calendarId, mutation) ?? + _event( + 'imported-master', + title: mutation.title ?? '', + providerCalendarId: calendarId, + location: mutation.location, + startTimeZone: mutation.startTimeZone, + endTimeZone: mutation.endTimeZone, + recurrenceJson: mutation.recurrence, + ); + _importedEventsByUid[iCalUid] = event; + remoteEventsById[event.providerEventId] = event; + return event; + } + @override Future updateEvent({ required String calendarId, diff --git a/test/features/sync/calendar_sync_engine_test.dart b/test/features/sync/calendar_sync_engine_test.dart index bd0ed250..37401951 100644 --- a/test/features/sync/calendar_sync_engine_test.dart +++ b/test/features/sync/calendar_sync_engine_test.dart @@ -13,10 +13,19 @@ import 'package:busymax/src/features/calendar/data/calendar_repository.dart'; import 'package:busymax/src/features/calendar/presentation/event_editor_draft.dart'; import 'package:busymax/src/features/notifications/notification_schedule_service.dart'; import 'package:busymax/src/features/sync/calendar_sync_engine.dart'; +import 'package:busymax/src/features/sync/cloud_calendar_range_coverage_service.dart'; +import 'package:busymax/src/microsoft_calendar/microsoft_calendar_api_client.dart'; +import 'package:busymax/src/microsoft_calendar/microsoft_calendar_errors.dart'; +import 'package:busymax/src/microsoft_calendar/microsoft_calendar_mapper.dart'; +import 'package:busymax/src/microsoft_calendar/microsoft_shared_calendar_address.dart'; import 'package:busymax/src/providers/busy_provider.dart'; +import 'package:busymax/src/schedule/schedule_repository.dart'; +import 'package:busymax/src/schedule/schedule_range.dart'; import 'package:drift/drift.dart'; import 'package:drift/native.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; void main() { late AppDatabase database; @@ -891,6 +900,181 @@ void main() { }, ); + test( + 'owner-context calendar remains after /me snapshot and uses range view', + () async { + await _insertAccount(database, provider: BusyProvider.microsoft); + final key = const MicrosoftSharedPrimaryCalendarAddress( + owner: 'owner@example.com', + graphCalendarId: 'owner-calendar', + ).sourceCalendarId; + await _insertSource( + database, + CalendarSourceDto( + provider: BusyProvider.microsoft, + providerCalendarId: key, + summary: 'Owner', + primaryCalendar: false, + readOnly: true, + ), + ); + final requests = []; + final client = MicrosoftCalendarApiClient( + httpClient: MockClient((request) async { + requests.add(request); + if (request.url.path == '/v1.0/me/calendars') { + return http.Response( + jsonEncode({ + 'value': [ + { + 'id': 'recipient-local', + 'name': 'Local share', + 'canEdit': false, + }, + ], + }), + 200, + ); + } + if (request.url.path.endsWith('/calendar')) { + return http.Response( + jsonEncode({ + 'id': 'owner-calendar', + 'name': 'Owner', + 'canEdit': false, + 'isDefaultCalendar': true, + }), + 200, + ); + } + return http.Response(jsonEncode({'value': []}), 200); + }), + baseUri: Uri.parse('https://graph.microsoft.com/v1.0'), + responseTimeZone: 'UTC', + ); + await CalendarSyncEngine( + database: database, + client: client, + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 7, 10), + ).fullSync(); + final sources = await database.select(database.calendarSources).get(); + expect(sources, hasLength(2)); + expect( + sources + .where((source) => source.providerCalendarId == key) + .single + .isDeleted, + isFalse, + ); + expect( + requests.any( + (request) => request.url.path.contains( + '/users/owner%40example.com/calendars/owner-calendar/calendarView', + ), + ), + isTrue, + ); + expect( + requests.any( + (request) => request.url.path.contains( + '/me/calendars/recipient-local/calendarView', + ), + ), + isTrue, + ); + expect( + requests.any( + (request) => request.url.path.contains( + '/users/owner%40example.com/calendarView/delta', + ), + ), + isFalse, + ); + }, + ); + + test('removing an opened owner calendar wins an in-flight refresh', () async { + await _insertAccount(database, provider: BusyProvider.microsoft); + final key = const MicrosoftSharedPrimaryCalendarAddress( + owner: 'owner@example.com', + graphCalendarId: 'owner-calendar', + ).sourceCalendarId; + final repository = CalendarRepository(database: database); + final source = CalendarSourceDto( + provider: BusyProvider.microsoft, + providerCalendarId: key, + summary: 'Owner', + readOnly: true, + isRemovable: true, + ); + await repository.upsertSource(accountId: 'account', source: source); + final entered = Completer(); + final release = Completer(); + final requests = []; + final client = MicrosoftCalendarApiClient( + httpClient: MockClient((request) async { + requests.add(request); + if (request.url.path == '/v1.0/me/calendars') { + return http.Response(jsonEncode({'value': []}), 200); + } + if (request.url.path.endsWith('/calendar')) { + entered.complete(); + await release.future; + return http.Response( + jsonEncode({ + 'id': 'owner-calendar', + 'name': 'Owner', + 'canEdit': true, + }), + 200, + ); + } + return http.Response(jsonEncode({'value': []}), 200); + }), + baseUri: Uri.parse('https://graph.microsoft.com/v1.0'), + responseTimeZone: 'UTC', + ); + final engine = CalendarSyncEngine( + database: database, + client: client, + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 7, 10), + ); + final running = engine.fullSync(); + await entered.future; + await repository.deleteLocalSource('account|microsoft|$key'); + release.complete(); + await running; + expect( + (await database.select(database.calendarSources).getSingle()).isDeleted, + isTrue, + ); + expect(await database.select(database.pendingOps).get(), isEmpty); + expect( + requests.where((request) => request.url.path.contains('calendarView')), + isEmpty, + ); + await engine.fullSync(); + expect( + requests.where((request) => request.url.path.endsWith('/calendar')), + hasLength(1), + ); + await repository.upsertSource( + accountId: 'account', + source: source, + reopenLocallyRemovedOwner: true, + ); + expect( + (await database.select(database.calendarSources).get()), + hasLength(1), + ); + expect( + (await database.select(database.calendarSources).getSingle()).isDeleted, + isFalse, + ); + }); + test( 'Google full sync tombstones calendars absent from the calendar list', () async { @@ -1039,6 +1223,241 @@ void main() { }, ); + test( + 'A Microsoft ID-only occurrence removal deletes its original row and reminder', + () async { + const source = CalendarSourceDto( + provider: BusyProvider.microsoft, + providerCalendarId: 'cal-primary', + summary: 'Calendar', + primaryCalendar: true, + ); + await _insertAccount(database, provider: BusyProvider.microsoft); + await _insertSource(database, source); + final repository = CalendarRepository(database: database); + await repository.upsertEvent( + accountId: 'account', + event: const CalendarEventDto( + provider: BusyProvider.microsoft, + providerCalendarId: 'cal-primary', + providerEventId: 'occurrence-1', + providerRecurringEventId: 'master-1', + providerOriginalStartKey: '2026-07-15T09:00:00Z', + title: 'Occurrence', + startDateTime: '2026-07-15T09:00:00Z', + endDateTime: '2026-07-15T10:00:00Z', + remindersJson: { + 'isReminderOn': true, + 'reminderMinutesBeforeStart': 10, + }, + ), + ); + await NotificationScheduleService( + database: database, + nowUtc: () => DateTime.utc(2026, 7, 15, 8), + ).rebuildUpcomingEventNotifications('account'); + expect( + await database.select(database.notificationSchedule).get(), + hasLength(1), + ); + expect( + await ScheduleRepository( + database, + ).listItems(range: ScheduleRange.day(DateTime(2026, 7, 15))), + isNotEmpty, + ); + await repository.saveSyncState( + accountId: 'account', + provider: BusyProvider.microsoft, + syncKind: 'events', + calendarSourceId: CalendarRepository.sourceId( + accountId: 'account', + provider: BusyProvider.microsoft, + providerCalendarId: 'cal-primary', + ), + rangeStart: DateTime.utc(2025, 7).toIso8601String(), + rangeEnd: DateTime.utc(2028, 8).toIso8601String(), + cursorKind: 'microsoft_delta_link', + cursorValue: 'https://graph.example/delta', + full: true, + ); + final marker = microsoftCalendarEventFromJson('cal-primary', { + 'id': 'occurrence-1', + '@removed': {'reason': 'deleted'}, + }); + final client = _FakeCalendarClient( + provider: BusyProvider.microsoft, + calendars: const [source], + pages: [ + CalendarSyncPageDto(events: [marker]), + CalendarSyncPageDto(events: [marker]), + ], + missingEventIds: const {'occurrence-1'}, + ); + final engine = CalendarSyncEngine( + database: database, + client: client, + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 7, 15, 8), + ); + await engine.incrementalSync(); + await engine.incrementalSync(); + final rows = await database.select(database.calendarEvents).get(); + expect(rows, hasLength(1)); + expect(rows.single.providerOriginalStartKey, '2026-07-15T09:00:00Z'); + expect(rows.single.isDeleted, isTrue); + expect( + await ScheduleRepository( + database, + ).listItems(range: ScheduleRange.day(DateTime(2026, 7, 15))), + isEmpty, + ); + expect( + await database.select(database.notificationSchedule).get(), + isEmpty, + ); + }, + ); + + test( + 'A Microsoft view removal preserves an event cached from another range', + () async { + const source = CalendarSourceDto( + provider: BusyProvider.microsoft, + providerCalendarId: 'cal-primary', + summary: 'Calendar', + primaryCalendar: true, + ); + await _insertAccount(database, provider: BusyProvider.microsoft); + await _insertSource(database, source); + const future = CalendarEventDto( + provider: BusyProvider.microsoft, + providerCalendarId: 'cal-primary', + providerEventId: 'moved-future', + title: 'Still exists', + startDateTime: '2030-01-15T09:00:00Z', + endDateTime: '2030-01-15T10:00:00Z', + ); + final marker = microsoftCalendarEventFromJson('cal-primary', { + 'id': 'moved-future', + '@removed': {'reason': 'deleted'}, + }); + final client = _FakeCalendarClient( + provider: BusyProvider.microsoft, + calendars: const [source], + pages: [ + CalendarSyncPageDto(events: [marker]), + ], + eventsById: const {'moved-future': future}, + onListEvents: (_, _, _) async => const [future], + ); + final engine = CalendarSyncEngine( + database: database, + client: client, + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 7, 10), + ); + await engine.retrieveMonth(DateTime.utc(2030, 1)); + await CalendarRepository(database: database).saveSyncState( + accountId: 'account', + provider: BusyProvider.microsoft, + syncKind: 'events', + calendarSourceId: CalendarRepository.sourceId( + accountId: 'account', + provider: BusyProvider.microsoft, + providerCalendarId: 'cal-primary', + ), + rangeStart: DateTime.utc(2025, 7).toIso8601String(), + rangeEnd: DateTime.utc(2028, 8).toIso8601String(), + cursorKind: 'microsoft_delta_link', + cursorValue: 'https://graph.example/delta', + full: true, + ); + await engine.incrementalSync(); + final rows = await database.select(database.calendarEvents).get(); + expect(rows, hasLength(1)); + expect(rows.single.isDeleted, isFalse); + expect(rows.single.startDateTime, '2030-01-15T09:00:00Z'); + expect( + await ScheduleRepository( + database, + ).listItems(range: ScheduleRange.day(DateTime(2030, 1, 15))), + hasLength(1), + ); + }, + ); + + test( + 'A Microsoft confirmed removal leaves a dirty local occurrence intact', + () async { + const source = CalendarSourceDto( + provider: BusyProvider.microsoft, + providerCalendarId: 'cal-primary', + summary: 'Calendar', + primaryCalendar: true, + ); + await _insertAccount(database, provider: BusyProvider.microsoft); + await _insertSource(database, source); + final repository = CalendarRepository(database: database); + await repository.upsertEvent( + accountId: 'account', + event: const CalendarEventDto( + provider: BusyProvider.microsoft, + providerCalendarId: 'cal-primary', + providerEventId: 'occurrence-1', + providerRecurringEventId: 'master-1', + providerOriginalStartKey: '2026-07-15T09:00:00Z', + title: 'Pending edit', + startDateTime: '2026-07-15T09:00:00Z', + endDateTime: '2026-07-15T10:00:00Z', + ), + ); + await database + .update(database.calendarEvents) + .write(const CalendarEventsCompanion(syncStatus: Value('dirty'))); + await repository.saveSyncState( + accountId: 'account', + provider: BusyProvider.microsoft, + syncKind: 'events', + calendarSourceId: CalendarRepository.sourceId( + accountId: 'account', + provider: BusyProvider.microsoft, + providerCalendarId: 'cal-primary', + ), + rangeStart: DateTime.utc(2025, 7).toIso8601String(), + rangeEnd: DateTime.utc(2028, 8).toIso8601String(), + cursorKind: 'microsoft_delta_link', + cursorValue: 'https://graph.example/delta', + full: true, + ); + final client = _FakeCalendarClient( + provider: BusyProvider.microsoft, + calendars: const [source], + pages: [ + CalendarSyncPageDto( + events: [ + microsoftCalendarEventFromJson('cal-primary', { + 'id': 'occurrence-1', + '@removed': {'reason': 'deleted'}, + }), + ], + ), + ], + missingEventIds: const {'occurrence-1'}, + ); + await CalendarSyncEngine( + database: database, + client: client, + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 7, 15, 8), + ).incrementalSync(); + final rows = await database.select(database.calendarEvents).get(); + expect(rows, hasLength(1)); + expect(rows.single.isDeleted, isFalse); + expect(rows.single.syncStatus, 'dirty'); + }, + ); + test( 'Microsoft non-primary incremental sync reconciles each full snapshot', () async { @@ -1111,6 +1530,461 @@ void main() { ]); }, ); + + test('range snapshot is separate from the baseline cursor', () async { + const source = CalendarSourceDto( + provider: BusyProvider.google, + providerCalendarId: 'cal-1', + summary: 'Work', + ); + await _insertAccount(database, provider: BusyProvider.google); + await _insertSource(database, source); + final sourceId = CalendarRepository.sourceId( + accountId: 'account', + provider: BusyProvider.google, + providerCalendarId: 'cal-1', + ); + final repository = CalendarRepository(database: database); + await repository.saveSyncState( + accountId: 'account', + provider: BusyProvider.google, + syncKind: 'events', + calendarSourceId: sourceId, + cursorKind: 'google_sync_token', + cursorValue: 'baseline-token', + ); + final client = _FakeCalendarClient( + provider: BusyProvider.google, + calendars: const [source], + pages: const [], + onListEvents: (_, _, _) async => [ + const CalendarEventDto( + provider: BusyProvider.google, + providerCalendarId: 'cal-1', + providerEventId: 'old', + title: 'Old event', + startDateTime: '2020-01-10T10:00:00Z', + endDateTime: '2020-01-10T11:00:00Z', + ), + ], + ); + await CalendarSyncEngine( + database: database, + client: client, + accountId: 'account', + ).retrieveMonth(DateTime.utc(2020)); + + expect(client.listCalls, [DateTime.utc(2020)]); + expect( + (await repository.syncState( + accountId: 'account', + provider: BusyProvider.google, + syncKind: 'events', + calendarSourceId: sourceId, + ))?.cursorValue, + 'baseline-token', + ); + expect( + (await database.select(database.calendarEvents).get()).single.title, + 'Old event', + ); + expect( + await repository.syncState( + accountId: 'account', + provider: BusyProvider.google, + syncKind: 'events_range_2020_1', + calendarSourceId: sourceId, + ), + isA(), + ); + }); + + test( + 'failed range retrieval retains cached events and no coverage', + () async { + const source = CalendarSourceDto( + provider: BusyProvider.microsoft, + providerCalendarId: 'cal-1', + summary: 'Work', + ); + await _insertAccount(database, provider: BusyProvider.microsoft); + await _insertSource(database, source); + final eventId = await _insertEvent( + database, + provider: BusyProvider.microsoft, + providerCalendarId: 'cal-1', + ); + final client = _FakeCalendarClient( + provider: BusyProvider.microsoft, + calendars: const [source], + pages: const [], + onListEvents: (_, _, _) async => throw StateError('page two failed'), + ); + final engine = CalendarSyncEngine( + database: database, + client: client, + accountId: 'account', + ); + await expectLater( + engine.retrieveMonth(DateTime.utc(2026, 7)), + throwsStateError, + ); + expect( + (await (database.select( + database.calendarEvents, + )..where((row) => row.id.equals(eventId))).getSingle()).isDeleted, + isFalse, + ); + expect(await database.select(database.syncCursors).get(), isEmpty); + }, + ); + + test( + 'overlapping range requests share a month and reuse fresh coverage', + () async { + const source = CalendarSourceDto( + provider: BusyProvider.google, + providerCalendarId: 'cal-1', + summary: 'Work', + ); + await _insertAccount(database, provider: BusyProvider.google); + await _insertSource(database, source); + final release = Completer>(); + final client = _FakeCalendarClient( + provider: BusyProvider.google, + calendars: const [source], + pages: const [], + onListEvents: (_, _, _) => release.future, + ); + final coverage = CloudCalendarRangeCoverageService( + database: database, + nowUtc: () => DateTime.utc(2026, 7, 15), + engineForAccount: (_, _) => CalendarSyncEngine( + database: database, + client: client, + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 7, 15), + ), + ); + final first = coverage.ensureRange( + DateTime.utc(2026, 7, 3), + DateTime.utc(2026, 7, 12), + ); + final second = coverage.ensureRange( + DateTime.utc(2026, 7, 10), + DateTime.utc(2026, 7, 20), + ); + await Future.delayed(Duration.zero); + expect(client.listCalls, [DateTime.utc(2026, 7)]); + release.complete([]); + expect(await first, isTrue); + expect(await second, isTrue); + expect( + await coverage.ensureRange( + DateTime.utc(2026, 7, 21), + DateTime.utc(2026, 7, 22), + ), + isTrue, + ); + expect(client.listCalls, hasLength(1)); + }, + ); + + test( + 'explicit search source retrieves an unselected cloud calendar', + () async { + const source = CalendarSourceDto( + provider: BusyProvider.google, + providerCalendarId: 'search-only', + summary: 'Search only', + ); + await _insertAccount(database, provider: BusyProvider.google); + await _insertSource(database, source); + final sourceId = CalendarRepository.sourceId( + accountId: 'account', + provider: BusyProvider.google, + providerCalendarId: 'search-only', + ); + await (database.update( + database.calendarSources, + )..where((row) => row.id.equals(sourceId))).write( + const CalendarSourcesCompanion( + selected: Value(false), + hidden: Value(true), + ), + ); + final client = _FakeCalendarClient( + provider: BusyProvider.google, + calendars: const [source], + pages: const [], + onListEvents: (calendarId, _, _) async => [ + CalendarEventDto( + provider: BusyProvider.google, + providerCalendarId: calendarId, + providerEventId: 'older-event', + title: 'Older event', + startDateTime: '2020-01-10T10:00:00Z', + endDateTime: '2020-01-10T11:00:00Z', + ), + ], + ); + final coverage = CloudCalendarRangeCoverageService( + database: database, + engineForAccount: (_, _) => CalendarSyncEngine( + database: database, + client: client, + accountId: 'account', + ), + ); + + expect( + await coverage.ensureRange( + DateTime.utc(2020, 1, 1), + DateTime.utc(2020, 2, 1), + sourceIds: {sourceId}, + sourceFilterActive: true, + ), + isTrue, + ); + expect(client.listCalls, [DateTime.utc(2020)]); + expect( + (await database.select(database.calendarEvents).get()).single.title, + 'Older event', + ); + expect( + (await (database.select( + database.calendarSources, + )..where((row) => row.id.equals(sourceId))).getSingle()).selected, + isFalse, + ); + }, + ); + + test( + 'changing explicit sources does not inherit an in-flight month', + () async { + const firstSource = CalendarSourceDto( + provider: BusyProvider.google, + providerCalendarId: 'first', + summary: 'First', + ); + const secondSource = CalendarSourceDto( + provider: BusyProvider.google, + providerCalendarId: 'second', + summary: 'Second', + ); + await _insertAccount(database, provider: BusyProvider.google); + await _insertSource(database, firstSource); + await _insertSource(database, secondSource); + String sourceId(String calendarId) => CalendarRepository.sourceId( + accountId: 'account', + provider: BusyProvider.google, + providerCalendarId: calendarId, + ); + final firstStarted = Completer(); + final releaseFirst = Completer>(); + final client = _FakeCalendarClient( + provider: BusyProvider.google, + calendars: const [firstSource, secondSource], + pages: const [], + onListEvents: (calendarId, _, _) { + if (calendarId == 'first') { + firstStarted.complete(); + return releaseFirst.future; + } + return Future.value(const []); + }, + ); + final coverage = CloudCalendarRangeCoverageService( + database: database, + engineForAccount: (_, _) => CalendarSyncEngine( + database: database, + client: client, + accountId: 'account', + ), + ); + final first = coverage.ensureRange( + DateTime.utc(2020, 1), + DateTime.utc(2020, 2), + sourceIds: {sourceId('first')}, + sourceFilterActive: true, + ); + await firstStarted.future; + expect( + await coverage + .ensureRange( + DateTime.utc(2020, 1), + DateTime.utc(2020, 2), + sourceIds: {sourceId('second')}, + sourceFilterActive: true, + ) + .timeout(const Duration(seconds: 3)), + isTrue, + ); + expect(client.listCalendarIds, containsAll(['first', 'second'])); + releaseFirst.complete(const []); + expect(await first, isTrue); + }, + ); + + test('an unrelated account failure cannot fail an explicit source', () async { + const selected = CalendarSourceDto( + provider: BusyProvider.google, + providerCalendarId: 'selected', + summary: 'Selected', + ); + const unrelated = CalendarSourceDto( + provider: BusyProvider.google, + providerCalendarId: 'unrelated', + summary: 'Unrelated', + ); + await _insertAccount(database, provider: BusyProvider.google); + await _insertSource(database, selected); + await database + .into(database.accounts) + .insert( + AccountsCompanion.insert( + id: 'other-account', + provider: BusyProvider.google.storageValue, + authority: 'https://accounts.google.com', + providerAccountId: 'other-account', + credentialKind: 'oauth', + authState: const Value('signed_in'), + grantedScopes: const Value(''), + createdAtUtc: '2026-07-01T00:00:00.000Z', + updatedAtUtc: '2026-07-01T00:00:00.000Z', + ), + ); + await CalendarRepository( + database: database, + ).upsertSource(accountId: 'other-account', source: unrelated); + final selectedId = CalendarRepository.sourceId( + accountId: 'account', + provider: BusyProvider.google, + providerCalendarId: 'selected', + ); + final requestedClient = _FakeCalendarClient( + provider: BusyProvider.google, + calendars: const [selected], + pages: const [], + onListEvents: (_, _, _) async => const [], + ); + final unrelatedClient = _FakeCalendarClient( + provider: BusyProvider.google, + calendars: const [unrelated], + pages: const [], + onListEvents: (_, _, _) async => throw StateError('unrelated failure'), + ); + final coverage = CloudCalendarRangeCoverageService( + database: database, + engineForAccount: (accountId, _) => CalendarSyncEngine( + database: database, + client: accountId == 'account' ? requestedClient : unrelatedClient, + accountId: accountId, + ), + ); + expect( + await coverage.ensureRange( + DateTime.utc(2020, 1), + DateTime.utc(2020, 2), + sourceIds: {selectedId}, + sourceFilterActive: true, + ), + isTrue, + ); + expect(requestedClient.listCalls, hasLength(1)); + expect(unrelatedClient.listCalls, isEmpty); + }); + + test( + 'range reconciliation deletes only clean missing events inside month', + () async { + const source = CalendarSourceDto( + provider: BusyProvider.google, + providerCalendarId: 'cal-1', + summary: 'Work', + ); + await _insertAccount(database, provider: BusyProvider.google); + await _insertSource(database, source); + final repository = CalendarRepository(database: database); + Future insert(String id, String start) => repository.upsertEvent( + accountId: 'account', + event: CalendarEventDto( + provider: BusyProvider.google, + providerCalendarId: 'cal-1', + providerEventId: id, + title: id, + startDateTime: start, + endDateTime: DateTime.parse( + start, + ).add(const Duration(hours: 1)).toIso8601String(), + rawJson: {'id': id}, + ), + ); + await insert('missing-july', '2026-07-04T09:00:00Z'); + await insert('dirty-july', '2026-07-05T09:00:00Z'); + await insert('outside-august', '2026-08-04T09:00:00Z'); + await (database.update( + database.calendarEvents, + )..where((row) => row.providerEventId.equals('dirty-july'))).write( + const CalendarEventsCompanion(syncStatus: Value('pending_update')), + ); + final client = _FakeCalendarClient( + provider: BusyProvider.google, + calendars: const [source], + pages: const [], + onListEvents: (_, _, _) async => const [], + ); + await CalendarSyncEngine( + database: database, + client: client, + accountId: 'account', + ).retrieveMonth(DateTime.utc(2026, 7)); + final rows = await database.select(database.calendarEvents).get(); + final byId = {for (final row in rows) row.providerEventId: row}; + expect(byId['missing-july']!.isDeleted, isTrue); + expect(byId['dirty-july']!.isDeleted, isFalse); + expect(byId['outside-august']!.isDeleted, isFalse); + }, + ); + + test('month reconciliation resolves offset-free provider zone', () async { + const source = CalendarSourceDto( + provider: BusyProvider.google, + providerCalendarId: 'cal-1', + summary: 'Work', + ); + await _insertAccount(database, provider: BusyProvider.google); + await _insertSource(database, source); + await CalendarRepository(database: database).upsertEvent( + accountId: 'account', + event: const CalendarEventDto( + provider: BusyProvider.google, + providerCalendarId: 'cal-1', + providerEventId: 'cross-month-zone', + title: 'Cross month', + startDateTime: '2026-07-31T14:00:00', + startTimeZone: 'Pacific/Pago_Pago', + endDateTime: '2026-07-31T15:00:00', + endTimeZone: 'Pacific/Pago_Pago', + ), + ); + final client = _FakeCalendarClient( + provider: BusyProvider.google, + calendars: const [source], + pages: const [], + onListEvents: (_, _, _) async => const [], + ); + await CalendarSyncEngine( + database: database, + client: client, + accountId: 'account', + ).retrieveMonth(DateTime.utc(2026, 8)); + expect( + (await database.select(database.calendarEvents).get()).single.isDeleted, + isTrue, + ); + }); } const _expandedGoogleState = '{"singleEvents":true,"version":1}'; @@ -1208,12 +2082,23 @@ class _FakeCalendarClient implements CloudCalendarClient { required this.calendars, required List pages, this.eventsById = const {}, + this.missingEventIds = const {}, + this.onListEvents, }) : _pages = List.of(pages); @override final BusyProvider provider; final List calendars; final Map eventsById; + final Set missingEventIds; + final Future> Function( + String calendarId, + DateTime start, + DateTime end, + )? + onListEvents; + final List listCalls = []; + final List listCalendarIds = []; final List _pages; final List<_SyncCall> syncCalls = []; Completer? secondPageStarted; @@ -1310,6 +2195,13 @@ class _FakeCalendarClient implements CloudCalendarClient { required String calendarId, required String eventId, }) async { + if (missingEventIds.contains(eventId)) { + throw const MicrosoftCalendarApiError( + statusCode: 404, + code: 'ErrorItemNotFound', + message: 'Not found', + ); + } final event = eventsById[eventId]; if (event == null) { throw StateError('No fake event exists for $eventId.'); @@ -1324,7 +2216,7 @@ class _FakeCalendarClient implements CloudCalendarClient { required DateTime rangeStart, required DateTime rangeEnd, }) { - throw UnimplementedError(); + return Future.value(const []); } @override @@ -1334,7 +2226,10 @@ class _FakeCalendarClient implements CloudCalendarClient { required DateTime rangeEnd, String? pageTokenOrUrl, }) { - throw UnimplementedError(); + listCalls.add(rangeStart); + listCalendarIds.add(calendarId); + return onListEvents?.call(calendarId, rangeStart, rangeEnd) ?? + Future.value(const []); } @override diff --git a/test/features/sync/pending_ops_replayer_test.dart b/test/features/sync/pending_ops_replayer_test.dart index f982ede3..1e471c69 100644 --- a/test/features/sync/pending_ops_replayer_test.dart +++ b/test/features/sync/pending_ops_replayer_test.dart @@ -44,6 +44,294 @@ void main() { await database.close(); }); + test('replay blocks legacy Docs-assigned note mutation', () async { + await database.tasksDao.upsertTask( + _task( + 'list-1', + 'assigned', + assignmentInfoJson: '{"surfaceType":"DOCUMENT"}', + ), + ); + await _enqueue( + database, + id: '01', + operation: 'patch_task', + taskListId: 'list-1', + taskId: 'assigned', + request: {'notes': 'Unsupported'}, + ); + + expect( + await PendingOpsReplayer( + database: database, + apiClient: apiClient, + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 6, 4, 1), + ).replayDueOps(), + 0, + ); + expect(apiClient.calls, isEmpty); + expect( + (await database.pendingOpsDao.getOp('01'))?.lastErrorCode, + 'unsupported_assigned_task', + ); + }); + + test( + 'replay blocks notes when a task became Docs-assigned remotely', + () async { + const baseline = {'id': 'task', 'title': 'Task'}; + await database.tasksDao.upsertTask( + _task( + 'list-1', + 'task', + updatedUtc: _now, + rawJson: jsonEncode(baseline), + ), + ); + await _enqueue( + database, + id: '01', + operation: 'patch_task', + taskListId: 'list-1', + taskId: 'task', + request: {'notes': 'Now forbidden'}, + baselineUpdatedUtc: _now, + baselineRawJson: jsonEncode(baseline), + ); + apiClient.remoteTask = TaskDto.fromJson({ + ...baseline, + 'updated': _now, + 'assignmentInfo': {'surfaceType': 'DOCUMENT'}, + }); + + expect( + await PendingOpsReplayer( + database: database, + apiClient: apiClient, + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 6, 4, 1), + ).replayDueOps(), + 0, + ); + expect(apiClient.calls, isEmpty); + expect( + (await database.pendingOpsDao.getOp('01'))?.lastErrorCode, + 'unsupported_assigned_task', + ); + }, + ); + + test('replay blocks a parent that became assigned remotely', () async { + const child = {'id': 'child', 'title': 'Child'}; + const parent = {'id': 'parent', 'title': 'Parent'}; + await database.tasksDao.upsertTask( + _task('list-1', 'child', updatedUtc: _now, rawJson: jsonEncode(child)), + ); + await database.tasksDao.upsertTask( + _task('list-1', 'parent', updatedUtc: _now, rawJson: jsonEncode(parent)), + ); + await _enqueue( + database, + id: '01', + operation: 'move_task', + taskListId: 'list-1', + taskId: 'child', + request: {'parent': 'parent'}, + baselineUpdatedUtc: _now, + baselineRawJson: jsonEncode(child), + ); + apiClient.remoteTasksById.addAll({ + 'child': TaskDto.fromJson({...child, 'updated': _now}), + 'parent': TaskDto.fromJson({ + ...parent, + 'updated': _now, + 'assignmentInfo': {'surfaceType': 'SPACE'}, + }), + }); + + expect( + await PendingOpsReplayer( + database: database, + apiClient: apiClient, + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 6, 4, 1), + ).replayDueOps(), + 0, + ); + expect(apiClient.calls, isEmpty); + expect( + (await database.pendingOpsDao.getOp('01'))?.lastErrorCode, + 'unsupported_assigned_task', + ); + }); + + test('replay blocks creation below a newly assigned parent', () async { + await database.tasksDao.upsertTask(_task('list-1', 'parent')); + await _enqueue( + database, + id: '01', + operation: 'create_task', + taskListId: 'list-1', + taskId: 'local-child', + localTempId: 'local-child', + request: { + 'parent': 'parent', + 'body': {'title': 'Child'}, + }, + ); + apiClient.remoteTasksById['parent'] = TaskDto.fromJson({ + 'id': 'parent', + 'title': 'Parent', + 'assignmentInfo': {'surfaceType': 'DOCUMENT'}, + }); + + expect( + await PendingOpsReplayer( + database: database, + apiClient: apiClient, + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 6, 4, 1), + ).replayDueOps(), + 0, + ); + expect(apiClient.calls, isEmpty); + expect( + (await database.pendingOpsDao.getOp('01'))?.lastErrorCode, + 'unsupported_assigned_task', + ); + }); + + test('replay blocks assigned child and assigned parent moves', () async { + await database.tasksDao.upsertTask( + _task( + 'list-1', + 'assigned', + assignmentInfoJson: '{"surfaceType":"SPACE"}', + ), + ); + await database.tasksDao.upsertTask(_task('list-1', 'ordinary')); + await _enqueue( + database, + id: '01', + operation: 'move_task', + taskListId: 'list-1', + taskId: 'assigned', + request: {'parent': 'ordinary'}, + ); + await _enqueue( + database, + id: '02', + operation: 'move_task', + taskListId: 'list-1', + taskId: 'ordinary', + request: {'parent': 'assigned'}, + ); + + expect( + await PendingOpsReplayer( + database: database, + apiClient: apiClient, + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 6, 4, 1), + ).replayDueOps(), + 0, + ); + expect(apiClient.calls, isEmpty); + final ops = await database.select(database.pendingOps).get(); + expect( + ops.map((op) => op.lastErrorCode), + everyElement('unsupported_assigned_task'), + ); + }); + + test('replay keeps assigned top-level ordering available', () async { + const assigned = { + 'id': 'assigned', + 'title': 'Assigned', + 'assignmentInfo': {'surfaceType': 'DOCUMENT'}, + }; + await database.tasksDao.upsertTask( + _task( + 'list-1', + 'assigned', + updatedUtc: _now, + rawJson: jsonEncode(assigned), + assignmentInfoJson: jsonEncode(assigned['assignmentInfo']), + ), + ); + await _enqueue( + database, + id: '01', + operation: 'move_task', + taskListId: 'list-1', + taskId: 'assigned', + request: {'previous': 'sibling'}, + baselineUpdatedUtc: _now, + baselineRawJson: jsonEncode(assigned), + ); + apiClient.remoteTask = TaskDto.fromJson({...assigned, 'updated': _now}); + + expect( + await PendingOpsReplayer( + database: database, + apiClient: apiClient, + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 6, 4, 1), + ).replayDueOps(), + 1, + ); + expect(apiClient.calls, ['move_task:assigned']); + }); + + for (final confirmed in [false, true]) { + test('replay ${confirmed ? 'deletes' : 'blocks'} assigned source only ' + '${confirmed ? 'with' : 'without'} recorded confirmation', () async { + const raw = { + 'id': 'assigned', + 'title': 'Assigned', + 'assignmentInfo': {'surfaceType': 'SPACE'}, + }; + await database.tasksDao.upsertTask( + _task( + 'list-1', + 'assigned', + updatedUtc: _now, + rawJson: jsonEncode(raw), + assignmentInfoJson: jsonEncode(raw['assignmentInfo']), + ), + ); + await _enqueue( + database, + id: '01', + operation: 'delete_task', + taskListId: 'list-1', + taskId: 'assigned', + request: {if (confirmed) 'confirmedAssignedSourceDeletion': true}, + baselineUpdatedUtc: _now, + baselineRawJson: jsonEncode(raw), + ); + apiClient.remoteTask = TaskDto.fromJson({...raw, 'updated': _now}); + + expect( + await PendingOpsReplayer( + database: database, + apiClient: apiClient, + accountId: 'account', + nowUtc: () => DateTime.utc(2026, 6, 4, 1), + ).replayDueOps(), + confirmed ? 1 : 0, + ); + expect(apiClient.calls, confirmed ? ['delete_task:assigned'] : isEmpty); + if (!confirmed) { + expect( + (await database.pendingOpsDao.getOp('01'))?.lastErrorCode, + 'unsupported_assigned_task', + ); + } + }); + } + for (final lifecycle in ['snoozed', 'sent', 'dismissed']) { test( 'actual create replay preserves $lifecycle reminder identity', @@ -3912,6 +4200,9 @@ void main() { } test('Microsoft To Do 404 delete reconciles as success', () async { + await (database.update(database.accounts) + ..where((row) => row.id.equals('account'))) + .write(const AccountsCompanion(provider: Value('microsoft'))); final microsoftClient = _ThrowingMicrosoftTodoApiClient( deleteTaskError: const MicrosoftTodoApiError( statusCode: 404, @@ -4622,6 +4913,7 @@ class _FakeTaskRemoteClient implements TaskRemoteClient { GoogleTasksApiError? moveTaskError; TaskListDto? remoteTaskList; TaskDto? remoteTask; + final Map remoteTasksById = {}; TaskDto? remoteTaskAfterSecondPatch; TaskDto? remoteTaskAfterMove; DateTime? taskListPatchResultUpdated; @@ -4812,7 +5104,7 @@ class _FakeTaskRemoteClient implements TaskRemoteClient { required String taskListId, required String taskId, }) async { - return remoteTask ?? _taskDto(taskId); + return remoteTasksById[taskId] ?? remoteTask ?? _taskDto(taskId); } @override @@ -4955,6 +5247,7 @@ TasksCompanion _task( String? updatedUtc, String? rawJson, String? checklistItemsJson, + String? assignmentInfoJson, }) { return TasksCompanion.insert( accountId: 'account', @@ -4964,6 +5257,7 @@ TasksCompanion _task( updatedUtc: Value(updatedUtc), rawJson: rawJson ?? jsonEncode({'id': id, 'title': title}), microsoftChecklistItemsJson: Value(checklistItemsJson), + assignmentInfoJson: Value(assignmentInfoJson), localDirty: Value(id.startsWith('local-')), localCreated: Value(id.startsWith('local-')), createdLocalAtUtc: _now, diff --git a/test/features/tasks/data/tasks_repository_test.dart b/test/features/tasks/data/tasks_repository_test.dart index f1e9592e..bd1d652e 100644 --- a/test/features/tasks/data/tasks_repository_test.dart +++ b/test/features/tasks/data/tasks_repository_test.dart @@ -62,6 +62,106 @@ void main() { expect(mutationQueuedCalls, 1); }); + test( + 'Docs-assigned task rejects notes and hierarchy before local writes', + () async { + await database.tasksDao.upsertTask( + _task( + id: 'assigned', + position: '1', + assignmentInfoJson: const Value( + '{"surfaceType":"DOCUMENT","linkToTask":"https://docs.google.com/document/d/example"}', + ), + ), + ); + await database.tasksDao.upsertTask(_task(id: 'ordinary', position: '2')); + + await expectLater( + repository.patchTask( + 'list-1', + 'assigned', + const TaskPatchInput({'notes': 'Not allowed'}), + ), + throwsUnsupportedError, + ); + await expectLater( + repository.createTask( + 'list-1', + const TaskCreateInput(title: 'Child', parentTaskId: 'assigned'), + ), + throwsUnsupportedError, + ); + await expectLater( + repository.moveTask( + const TaskMoveInput( + sourceTaskListId: 'list-1', + taskId: 'assigned', + parentTaskId: 'ordinary', + ), + ), + throwsUnsupportedError, + ); + await expectLater( + repository.moveTask( + const TaskMoveInput( + sourceTaskListId: 'list-1', + taskId: 'ordinary', + parentTaskId: 'assigned', + ), + ), + throwsUnsupportedError, + ); + expect((await database.select(database.pendingOps).get()), isEmpty); + final tasks = await database.tasksDao.listTasks('account', 'list-1'); + expect(tasks, hasLength(2)); + expect(tasks.every((task) => !task.localDirty), isTrue); + expect(tasks.every((task) => task.parent == null), isTrue); + + await repository.patchTask( + 'list-1', + 'assigned', + const TaskPatchInput({'title': 'Allowed'}), + ); + await repository.moveTask( + const TaskMoveInput(sourceTaskListId: 'list-1', taskId: 'assigned'), + ); + expect((await database.select(database.pendingOps).get()), hasLength(2)); + }, + ); + + test( + 'assigned deletion requires explicit original-task confirmation', + () async { + await database.tasksDao.upsertTask( + _task( + id: 'assigned', + position: '1', + assignmentInfoJson: const Value('{"surfaceType":"DOCUMENT"}'), + ), + ); + + await expectLater( + repository.deleteTask('list-1', 'assigned'), + throwsUnsupportedError, + ); + expect(await database.select(database.pendingOps).get(), isEmpty); + final before = await database.select(database.tasks).getSingle(); + expect(before.localDirty, isFalse); + expect(before.pendingDelete, isFalse); + await repository.deleteTask( + 'list-1', + 'assigned', + confirmedAssignedSourceDeletion: true, + ); + final after = await database.select(database.tasks).getSingle(); + expect(after.pendingDelete, isTrue); + final op = await database.select(database.pendingOps).getSingle(); + expect(jsonDecode(op.requestJson), { + 'confirmedAssignedSourceDeletion': true, + }); + }, + ); + test('new task mutations form a chain rooted at its creation', () async { var tick = 0; repository = TasksRepository( @@ -645,6 +745,7 @@ TasksCompanion _task({ Value parentUid = const Value.absent(), Value icalUid = const Value.absent(), Value microsoftChecklistItemsJson = const Value.absent(), + Value assignmentInfoJson = const Value.absent(), String rawJson = '{}', }) { return TasksCompanion.insert( @@ -658,6 +759,7 @@ TasksCompanion _task({ parentUid: parentUid, icalUid: icalUid, microsoftChecklistItemsJson: microsoftChecklistItemsJson, + assignmentInfoJson: assignmentInfoJson, hidden: hidden, serverMissing: serverMissing, status: status, diff --git a/test/features/tasks/domain/task_source_links_test.dart b/test/features/tasks/domain/task_source_links_test.dart new file mode 100644 index 00000000..db63f98c --- /dev/null +++ b/test/features/tasks/domain/task_source_links_test.dart @@ -0,0 +1,29 @@ +import 'package:busymax/src/features/tasks/domain/task_source_links.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('Google supplied links are deduplicated and unsafe links omitted', () { + final links = googleTaskSourceLinks( + assignmentInfoJson: + '{"surfaceType":"DOCUMENT","linkToTask":"https://docs.google.com/document/d/example"}', + linksJson: + '[{"description":"Document","link":"https://docs.google.com/document/d/example"},{"type":"generic","link":"https://example.test/source"},{"type":"email","link":"javascript:alert(1)"}]', + webViewLink: 'https://tasks.google.com/task/example', + ); + expect(links.map((link) => link.url), [ + 'https://docs.google.com/document/d/example', + 'https://example.test/source', + 'https://tasks.google.com/task/example', + ]); + expect(links[1].label, 'generic'); + }); + + test('malformed source metadata leaves the valid task page available', () { + final links = googleTaskSourceLinks( + assignmentInfoJson: '{broken', + linksJson: '{broken', + webViewLink: 'https://tasks.google.com/task/example', + ); + expect(links.single.url, 'https://tasks.google.com/task/example'); + }); +} diff --git a/test/features/tasks/presentation/task_details_pane_test.dart b/test/features/tasks/presentation/task_details_pane_test.dart index 21eb3eeb..50078098 100644 --- a/test/features/tasks/presentation/task_details_pane_test.dart +++ b/test/features/tasks/presentation/task_details_pane_test.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:convert'; import 'dart:io'; import 'dart:ui'; import 'package:busymax/src/l10n/time_format_scope.dart'; @@ -10,6 +11,9 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; +// The file_selector package does not re-export its platform test seam. +// ignore: depend_on_referenced_packages +import 'package:file_selector_platform_interface/file_selector_platform_interface.dart'; import 'package:busymax/src/app/app_bootstrap.dart'; import 'package:busymax/src/app/busymax_design.dart'; import 'package:busymax/src/app/busymax_yaru_theme.dart'; @@ -24,6 +28,7 @@ import 'package:busymax/src/features/tasks/presentation/ical_task_fields_editor. import 'package:busymax/src/features/tasks/presentation/task_details_draft.dart'; import 'package:busymax/src/features/tasks/presentation/desktop_date_time_fields.dart'; import 'package:busymax/src/features/tasks/presentation/task_details_editor.dart'; +import 'package:busymax/src/features/schedule/presentation/attachment_upload_coordinator.dart'; import 'package:busymax/src/features/tasks/presentation/task_details_pane.dart'; import 'package:busymax/src/features/maps/domain/geographic_point.dart'; import 'package:busymax/src/features/maps/domain/location_result.dart'; @@ -37,6 +42,9 @@ import 'package:busymax/src/platform/native_dialog_service.dart'; import 'package:busymax/src/platform/native_menu_service.dart'; import 'package:busymax/src/providers/busy_provider.dart'; import 'package:busymax/src/google_tasks/oauth/oauth_service.dart'; +import 'package:busymax/src/microsoft_todo/api/microsoft_todo_api_client.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; import 'package:busymax/src/features/tasks/domain/task_capabilities.dart'; import 'package:busymax/src/features/tasks/domain/task_checklist_item.dart'; import 'package:yaru/yaru.dart'; @@ -62,7 +70,593 @@ String _testAuthority(BusyProvider provider) => switch (provider) { BusyProvider.webCal => 'https://calendar.example.test', }; +class _AttachmentTestFileSelector extends FileSelectorPlatform { + @override + Future openFile({ + List? acceptedTypeGroups, + String? initialDirectory, + String? confirmButtonText, + }) async => XFile.fromData( + Uint8List.fromList(const [1, 2, 3]), + path: '/test/agenda.txt', + mimeType: 'text/plain', + ); +} + void main() { + testWidgets('Linux task Add releases after confirmed upload', (tester) async { + final previousSelector = FileSelectorPlatform.instance; + FileSelectorPlatform.instance = _AttachmentTestFileSelector(); + addTearDown(() => FileSelectorPlatform.instance = previousSelector); + + final heldRefresh = Completer(); + var uploads = 0; + var held = false; + final client = MicrosoftTodoRestApiClient( + httpClient: MockClient((request) async { + if (request.method == 'GET' && + request.url.path.endsWith('/attachments')) { + if (uploads == 1 && !held) { + held = true; + return heldRefresh.future; + } + return http.Response(jsonEncode({'value': []}), 200); + } + if (request.method == 'POST' && + request.url.path.endsWith('/attachments')) { + uploads++; + return http.Response( + jsonEncode({ + 'id': 'task-attachment-a', + 'name': 'agenda.txt', + 'size': 3, + '@odata.type': '#microsoft.graph.taskFileAttachment', + }), + 201, + ); + } + return http.Response('{}', 404); + }), + baseUri: Uri.parse('https://graph.microsoft.com/v1.0'), + authorizationHeaderProvider: () async => 'Bearer token', + ); + final coordinator = AttachmentUploadCoordinator(); + final key = AttachmentUploadCoordinator.taskKey( + 'microsoft:m', + 'list-1', + 'task-1', + ); + await tester.pumpWidget( + ProviderScope( + overrides: [ + attachmentUploadCoordinatorProvider.overrideWith( + (ref) => coordinator, + ), + microsoftTodoApiClientForAccountProvider( + 'microsoft:m', + ).overrideWithValue(client), + ], + child: localizedTestApp( + child: Scaffold( + body: TaskDetailsEditor( + task: _switchTask('task-1', 'Task'), + provider: BusyProvider.microsoft, + taskLists: const [ + TaskListEntity( + accountId: 'microsoft:m', + id: 'list-1', + title: 'Tasks', + localDirty: false, + pendingDelete: false, + rawJson: '{}', + ), + ], + capabilities: microsoftTaskCollectionCapabilities, + localTimeZone: 'UTC', + accountLabel: 'Account', + onRefresh: () {}, + onSave: (_, _) async {}, + onCreateSubtask: (_) async {}, + onMoveToTop: () {}, + onDelete: () async {}, + onCancel: () {}, + ), + ), + ), + ), + ); + for (var i = 0; i < 4; i++) { + await tester.pump(); + } + final add = find.widgetWithText(FilledButton, 'Attachments').last; + await tester.ensureVisible(add); + await tester.tap(add); + for (var i = 0; i < 30 && !held; i++) { + await tester.pump(const Duration(milliseconds: 1)); + } + try { + expect(held, isTrue); + expect(coordinator.status(key), AttachmentUploadStatus.committed); + for (var i = 0; i < 3; i++) { + await tester.pump(); + } + expect(tester.widget(add).onPressed, isNot(equals(null))); + expect(uploads, 1); + } finally { + heldRefresh.complete(http.Response(jsonEncode({'value': []}), 200)); + await tester.pump(); + } + }); + + testWidgets( + 'Linux task detail permits another upload after confirmed refresh failure', + (tester) async { + var posts = 0; + var gets = 0; + final client = MicrosoftTodoRestApiClient( + httpClient: MockClient((request) async { + if (request.method == 'GET') { + gets++; + if (gets.isEven) { + throw http.ClientException('attachment list unavailable'); + } + return http.Response(jsonEncode({'value': []}), 200); + } + posts++; + return http.Response( + jsonEncode({ + 'id': 'attachment-a', + 'name': 'a.txt', + 'size': 1, + '@odata.type': '#microsoft.graph.taskFileAttachment', + }), + 201, + ); + }), + baseUri: Uri.parse('https://graph.microsoft.com/v1.0'), + authorizationHeaderProvider: () async => 'Bearer token', + ); + final coordinator = AttachmentUploadCoordinator(); + final key = AttachmentUploadCoordinator.taskKey( + 'microsoft:m', + 'list-1', + 'task-1', + ); + await coordinator.uploadTask( + client: client, + accountId: 'microsoft:m', + taskListId: 'list-1', + taskId: 'task-1', + name: 'a.txt', + contentType: 'text/plain', + bytes: const [1], + ); + await tester.pumpWidget( + ProviderScope( + overrides: [ + attachmentUploadCoordinatorProvider.overrideWith( + (ref) => coordinator, + ), + microsoftTodoApiClientForAccountProvider( + 'microsoft:m', + ).overrideWithValue(client), + ], + child: localizedTestApp( + child: Scaffold( + body: TaskDetailsEditor( + task: _switchTask('task-1', 'Task'), + provider: BusyProvider.microsoft, + taskLists: const [ + TaskListEntity( + accountId: 'microsoft:m', + id: 'list-1', + title: 'Tasks', + localDirty: false, + pendingDelete: false, + rawJson: '{}', + ), + ], + capabilities: microsoftTaskCollectionCapabilities, + localTimeZone: 'UTC', + accountLabel: 'Account', + onRefresh: () {}, + onSave: (_, _) async {}, + onCreateSubtask: (_) async {}, + onMoveToTop: () {}, + onDelete: () async {}, + onCancel: () {}, + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + final attachmentButtons = tester + .widgetList( + find.widgetWithText(FilledButton, 'Attachments'), + ) + .toList(); + expect(attachmentButtons, hasLength(2)); + expect( + attachmentButtons.every((button) => button.onPressed != null), + isTrue, + ); + expect(coordinator.canSubmit(key), isTrue); + await coordinator.uploadTask( + client: client, + accountId: 'microsoft:m', + taskListId: 'list-1', + taskId: 'task-1', + name: 'b.txt', + contentType: 'text/plain', + bytes: const [2], + ); + expect(posts, 2); + }, + ); + + testWidgets( + 'R3 Linux task attachment Add waits for direct-upload retry eligibility', + (tester) async { + final previousSelector = FileSelectorPlatform.instance; + FileSelectorPlatform.instance = _AttachmentTestFileSelector(); + addTearDown(() => FileSelectorPlatform.instance = previousSelector); + var now = DateTime.utc(2026, 10, 1); + var posts = 0; + final client = MicrosoftTodoRestApiClient( + httpClient: MockClient((request) async { + if (request.method == 'GET') { + return http.Response(jsonEncode({'value': []}), 200); + } + posts++; + if (posts == 1) { + return http.Response( + jsonEncode({ + 'error': {'code': 'TooManyRequests', 'message': 'Throttled'}, + }), + 429, + headers: {'retry-after': '30'}, + ); + } + return http.Response( + jsonEncode({ + 'id': 'task-attachment-a', + 'name': 'agenda.txt', + 'size': 3, + '@odata.type': '#microsoft.graph.taskFileAttachment', + }), + 201, + ); + }), + baseUri: Uri.parse('https://graph.microsoft.com/v1.0'), + authorizationHeaderProvider: () async => 'Bearer token', + ); + final coordinator = AttachmentUploadCoordinator(now: () => now); + final key = AttachmentUploadCoordinator.taskKey( + 'microsoft:m', + 'list-1', + 'task-1', + ); + await tester.pumpWidget( + ProviderScope( + overrides: [ + attachmentUploadCoordinatorProvider.overrideWith( + (ref) => coordinator, + ), + microsoftTodoApiClientForAccountProvider( + 'microsoft:m', + ).overrideWithValue(client), + ], + child: localizedTestApp( + child: Scaffold( + body: TaskDetailsEditor( + task: _switchTask('task-1', 'Task'), + provider: BusyProvider.microsoft, + taskLists: const [ + TaskListEntity( + accountId: 'microsoft:m', + id: 'list-1', + title: 'Tasks', + localDirty: false, + pendingDelete: false, + rawJson: '{}', + ), + ], + capabilities: microsoftTaskCollectionCapabilities, + localTimeZone: 'UTC', + accountLabel: 'Account', + onRefresh: () {}, + onSave: (_, _) async {}, + onCreateSubtask: (_) async {}, + onMoveToTop: () {}, + onDelete: () async {}, + onCancel: () {}, + ), + ), + ), + ), + ); + for (var i = 0; i < 4; i++) { + await tester.pump(); + } + final add = find.widgetWithText(FilledButton, 'Attachments').last; + await tester.ensureVisible(add); + await tester.tap(add); + for (var i = 0; i < 20 && posts == 0; i++) { + await tester.pump(const Duration(milliseconds: 1)); + } + for (var i = 0; i < 3; i++) { + await tester.pump(); + } + expect(posts, 1); + expect(coordinator.needsReconciliation(key), isFalse); + expect(tester.widget(add).onPressed, equals(null)); + expect( + find.text( + 'Attachment upload is temporarily rate-limited. Try again when the wait ends.', + ), + findsOneWidget, + ); + now = now.add(const Duration(seconds: 31)); + await tester.pump(const Duration(seconds: 31)); + expect(tester.widget(add).onPressed, isNot(equals(null))); + await tester.tap(add); + for (var i = 0; i < 20 && posts < 2; i++) { + await tester.pump(const Duration(milliseconds: 1)); + } + expect(posts, 2); + }, + ); + + testWidgets( + 'Linux task detail retains an uncertain upload until explicit review', + (tester) async { + var posts = 0; + final client = MicrosoftTodoRestApiClient( + httpClient: MockClient((request) async { + if (request.method == 'GET') { + return http.Response(jsonEncode({'value': []}), 200); + } + posts++; + throw http.ClientException('direct response lost'); + }), + baseUri: Uri.parse('https://graph.microsoft.com/v1.0'), + authorizationHeaderProvider: () async => 'Bearer token', + ); + final coordinator = AttachmentUploadCoordinator(); + final key = AttachmentUploadCoordinator.taskKey( + 'microsoft:m', + 'list-1', + 'task-1', + ); + await expectLater( + coordinator.uploadTask( + client: client, + accountId: 'microsoft:m', + taskListId: 'list-1', + taskId: 'task-1', + name: 'notes.txt', + contentType: 'text/plain', + bytes: [1], + ), + throwsA(isA()), + ); + expect( + await coordinator.reconcileTask( + client: client, + accountId: 'microsoft:m', + taskListId: 'list-1', + taskId: 'task-1', + ), + AttachmentUploadStatus.unresolved, + ); + await tester.pumpWidget( + ProviderScope( + overrides: [ + attachmentUploadCoordinatorProvider.overrideWith( + (ref) => coordinator, + ), + microsoftTodoApiClientForAccountProvider( + 'microsoft:m', + ).overrideWithValue(client), + ], + child: localizedTestApp( + child: Scaffold( + body: TaskDetailsEditor( + task: _switchTask('task-1', 'Task'), + provider: BusyProvider.microsoft, + taskLists: [ + TaskListEntity( + accountId: 'microsoft:m', + id: 'list-1', + title: 'Tasks', + localDirty: false, + pendingDelete: false, + rawJson: '{}', + ), + ], + capabilities: microsoftTaskCollectionCapabilities, + localTimeZone: 'UTC', + accountLabel: 'Account', + onRefresh: () {}, + onSave: (_, _) async {}, + onCreateSubtask: (_) async {}, + onMoveToTop: () {}, + onDelete: () async {}, + onCancel: () {}, + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + expect(coordinator.canSubmit(key), isFalse); + final retry = find.text('Retry'); + await tester.ensureVisible(retry); + await tester.tap(retry); + await tester.pumpAndSettle(); + expect(coordinator.canSubmit(key), isFalse); + await tester.tap(find.text('Retry').last); + await tester.pumpAndSettle(); + expect(coordinator.canSubmit(key), isTrue); + expect(posts, 1); + }, + ); + testWidgets('Linux Microsoft task opens linked resources on demand', ( + tester, + ) async { + var requests = 0; + final client = MicrosoftTodoRestApiClient( + httpClient: MockClient((request) async { + requests++; + if (request.url.path.endsWith('/attachments')) { + return http.Response( + jsonEncode({ + 'value': [ + { + 'id': 'attachment-1', + 'name': 'Notes.txt', + 'size': 12, + '@odata.type': '#microsoft.graph.taskFileAttachment', + }, + ], + }), + 200, + ); + } + expect( + request.url.path, + '/v1.0/me/todo/lists/list-1/tasks/task-1/linkedResources', + ); + return http.Response( + jsonEncode({ + 'value': [ + { + 'id': 'resource-1', + 'applicationName': 'Planner', + 'displayName': 'Launch plan', + 'webUrl': 'https://example.test/plan', + }, + ], + }), + 200, + ); + }), + baseUri: Uri.parse('https://graph.microsoft.com/v1.0'), + authorizationHeaderProvider: () async => 'Bearer token', + ); + await tester.pumpWidget( + ProviderScope( + overrides: [ + microsoftTodoApiClientForAccountProvider( + 'microsoft:m', + ).overrideWithValue(client), + ], + child: localizedTestApp( + child: Scaffold( + body: TaskDetailsEditor( + task: _switchTask('task-1', 'Task'), + provider: BusyProvider.microsoft, + taskLists: [ + TaskListEntity( + accountId: 'microsoft:m', + id: 'list-1', + title: 'Tasks', + localDirty: false, + pendingDelete: false, + rawJson: '{}', + ), + ], + capabilities: microsoftTaskCollectionCapabilities, + localTimeZone: 'UTC', + accountLabel: 'Account', + onRefresh: () {}, + onSave: (_, _) async {}, + onCreateSubtask: (_) async {}, + onMoveToTop: () {}, + onDelete: () async {}, + onCancel: () {}, + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + expect(requests, 0); + await tester.tap(find.text('Linked resources')); + await tester.pumpAndSettle(); + expect(requests, 1); + expect(find.text('Launch plan · Planner'), findsOneWidget); + await tester.tap(find.byKey(const Key('task-attachments-load'))); + await tester.pumpAndSettle(); + expect(requests, 2); + expect(find.text('Notes.txt'), findsOneWidget); + expect(find.text('Task'), findsWidgets); + }); + + testWidgets( + 'Docs-assigned task disables notes and child creation and warns before delete', + (tester) async { + var deleted = false; + await tester.pumpWidget( + localizedTestApp( + child: Scaffold( + body: TaskDetailsEditor( + task: _switchTask( + 'assigned', + 'Assigned task', + assignmentInfoJson: + '{"surfaceType":"DOCUMENT","linkToTask":"https://docs.google.com/document/d/example"}', + ), + taskLists: [ + TaskListEntity( + accountId: 'microsoft:m', + id: 'list-1', + title: 'Tasks', + localDirty: false, + pendingDelete: false, + rawJson: '{}', + ), + ], + capabilities: googleTaskCollectionCapabilities, + localTimeZone: 'UTC', + accountLabel: 'Account', + onRefresh: () {}, + onSave: (_, _) async {}, + onCreateSubtask: (_) async {}, + onMoveToTop: () {}, + onDelete: () async => deleted = true, + onCancel: () {}, + ), + ), + ), + ); + await tester.pumpAndSettle(); + expect( + tester + .widget(find.widgetWithText(TextField, 'Notes')) + .enabled, + isFalse, + ); + final create = tester.widget( + find.byKey(const ValueKey('create-subtask-action')), + ); + expect(create.enabled, isFalse); + expect(find.text('Open in provider'), findsOneWidget); + + _focusEditorShortcuts(tester); + await tester.sendKeyEvent(LogicalKeyboardKey.delete); + await tester.pumpAndSettle(); + expect( + find.textContaining('original task in Google Docs'), + findsOneWidget, + ); + await tester.tap(find.text('Cancel').last); + await tester.pumpAndSettle(); + expect(deleted, isFalse); + }, + ); + testWidgets('new task opens with the title field focused', (tester) async { await tester.pumpWidget( localizedTestApp( @@ -2557,7 +3151,11 @@ class _FakeTasksRepository implements TasksRepository { } @override - Future deleteTask(String taskListId, String taskId) async { + Future deleteTask( + String taskListId, + String taskId, { + bool confirmedAssignedSourceDeletion = false, + }) async { deleteCalls += 1; } @@ -2653,7 +3251,7 @@ class _SwitchingTasksRepository implements TasksRepository { dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); } -TaskEntity _switchTask(String id, String title) { +TaskEntity _switchTask(String id, String title, {String? assignmentInfoJson}) { return TaskEntity( accountId: 'microsoft:m', taskListId: 'list-1', @@ -2665,6 +3263,7 @@ TaskEntity _switchTask(String id, String title) { rawJson: '{}', updatedLocalAtUtc: '2026-06-04T00:00:00.000Z', status: 'needsAction', + assignmentInfoJson: assignmentInfoJson, ); } diff --git a/test/google_calendar/google_calendar_api_client_test.dart b/test/google_calendar/google_calendar_api_client_test.dart index d2f59c5d..7a798529 100644 --- a/test/google_calendar/google_calendar_api_client_test.dart +++ b/test/google_calendar/google_calendar_api_client_test.dart @@ -2,10 +2,13 @@ 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/core/auth/oauth_models.dart'; +import 'package:busymax/src/core/http/request_dispatch_exception.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'; import 'package:busymax/src/google_calendar/google_calendar_errors.dart'; +import 'package:busymax/src/google_calendar/google_calendar_models.dart'; import 'package:busymax/src/schedule/schedule_range.dart'; import 'package:busymax/src/schedule/schedule_repository.dart'; import 'package:drift/drift.dart'; @@ -15,6 +18,506 @@ import 'package:http/http.dart' as http; import 'package:http/testing.dart'; void main() { + for (final status in [403, 429]) { + test( + 'ACL $status quota response retains reason and retry timing', + () async { + var requests = 0; + final client = GoogleCalendarApiClient( + httpClient: MockClient((request) async { + requests++; + return http.Response( + jsonEncode({ + 'error': { + 'code': status, + 'message': 'Quota', + 'errors': [ + { + 'domain': 'usageLimits', + 'reason': 'userRateLimitExceeded', + }, + ], + }, + }), + status, + headers: {'retry-after': '91'}, + ); + }), + baseUri: Uri.parse('https://www.googleapis.com'), + ); + await expectLater( + client.addAclUser( + 'calendar', + email: 'friend@example.test', + role: 'reader', + ), + throwsA( + isA() + .having((e) => e.statusCode, 'status', status) + .having((e) => e.isRateLimited, 'throttle', true) + .having( + (e) => e.reasons, + 'reason', + contains('userRateLimitExceeded'), + ) + .having( + (e) => e.retryAfter, + 'retry-after', + const Duration(seconds: 91), + ), + ), + ); + expect(requests, 1); + }, + ); + } + + test('ACL permission denial is not a quota response', () async { + final client = GoogleCalendarApiClient( + httpClient: MockClient( + (request) async => http.Response( + jsonEncode({ + 'error': { + 'code': 403, + 'message': 'No access', + 'errors': [ + {'domain': 'global', 'reason': 'forbidden'}, + ], + }, + }), + 403, + ), + ), + baseUri: Uri.parse('https://www.googleapis.com'), + ); + await expectLater( + client.addAclUser( + 'calendar', + email: 'friend@example.test', + role: 'reader', + ), + throwsA( + isA() + .having((e) => e.isRateLimited, 'throttle', false) + .having((e) => e.reasons, 'reason', contains('forbidden')), + ), + ); + }); + + test('ACL authorization failure before dispatch retains its cause', () async { + const failure = OAuthException('TokenUnavailable', 'Token unavailable'); + var requests = 0; + final client = GoogleCalendarApiClient( + httpClient: MockClient((request) async { + requests++; + return _json({'items': []}); + }), + baseUri: Uri.parse('https://www.googleapis.com'), + authorizationHeaderProvider: () async => throw failure, + ); + await expectLater( + client.addAclUser( + 'calendar', + email: 'friend@example.test', + role: 'reader', + ), + throwsA( + isA() + .having( + (error) => error.kind, + 'kind', + RequestPreDispatchFailureKind.authentication, + ) + .having((error) => error.cause, 'cause', same(failure)), + ), + ); + expect(requests, 0); + }); + + test('ACL 401 followed by failed refresh is known uncommitted', () async { + const failure = OAuthRefreshException( + 'RefreshDenied', + 'Refresh denied', + statusCode: 400, + ); + var recovered = false; + var requests = 0; + var refreshes = 0; + final client = GoogleCalendarApiClient( + httpClient: MockClient((request) async { + requests++; + if (!recovered) return http.Response('{}', 401); + return _json({ + 'id': 'grant', + 'role': 'reader', + 'scope': {'type': 'user', 'value': 'friend@example.test'}, + }); + }), + baseUri: Uri.parse('https://www.googleapis.com'), + authorizationHeaderProvider: () async => 'Bearer token', + unauthorizedRefreshProvider: () async { + refreshes++; + throw failure; + }, + ); + await expectLater( + client.addAclUser( + 'calendar', + email: 'friend@example.test', + role: 'reader', + ), + throwsA( + isA() + .having( + (error) => error.kind, + 'kind', + RequestPreDispatchFailureKind.authentication, + ) + .having((error) => error.cause, 'cause', same(failure)), + ), + ); + expect(requests, 1); + expect(refreshes, 1); + recovered = true; + await client.addAclUser( + 'calendar', + email: 'friend@example.test', + role: 'reader', + ); + expect(requests, 2); + expect(refreshes, 1); + }); + + test( + 'lost ACL response is not a known-unsent authentication failure', + () async { + var requests = 0; + final client = GoogleCalendarApiClient( + httpClient: MockClient((request) async { + requests++; + throw http.ClientException('response lost'); + }), + baseUri: Uri.parse('https://www.googleapis.com'), + authorizationHeaderProvider: () async => 'Bearer token', + ); + await expectLater( + client.addAclUser( + 'calendar', + email: 'friend@example.test', + role: 'reader', + ), + throwsA(isA()), + ); + expect(requests, 1); + }, + ); + + test( + 'ACL management pages rules and targets only the selected calendar', + () async { + final requests = []; + final client = _client((request) { + requests.add(request); + if (request.method == 'GET') { + return _json({ + 'items': [ + { + 'id': 'user:alex@example.test', + 'role': 'reader', + 'scope': {'type': 'user', 'value': 'alex@example.test'}, + }, + ], + if (request.url.queryParameters['pageToken'] == null) + 'nextPageToken': 'second', + }); + } + return _json({ + 'id': 'user:alex@example.test', + 'role': request.method == 'POST' ? 'writer' : 'reader', + 'scope': {'type': 'user', 'value': 'alex@example.test'}, + }); + }); + final rules = await client.listAclRules('team@example.test'); + expect(rules, hasLength(2)); + expect( + requests.map((request) => request.url.path), + everyElement('/calendar/v3/calendars/team%40example.test/acl'), + ); + expect(requests.last.url.queryParameters['pageToken'], 'second'); + final added = await client.addAclUser( + 'team@example.test', + email: 'alex@example.test', + role: 'writer', + ); + expect(added.role, 'writer'); + expect(jsonDecode(requests.last.body), { + 'role': 'writer', + 'scope': {'type': 'user', 'value': 'alex@example.test'}, + }); + await expectLater( + client.addAclUser('team', email: 'alex@example.test', role: 'owner'), + throwsArgumentError, + ); + }, + ); + test('ACL role changes and revocation protect immutable grants', () async { + final requests = []; + final client = _client((request) { + requests.add(request); + if (request.method == 'DELETE') return http.Response('', 204); + return _json({ + 'id': 'user:alex@example.test', + 'role': 'writer', + 'scope': {'type': 'user', 'value': 'alex@example.test'}, + }); + }); + const user = GoogleAclRule( + id: 'user:alex@example.test', + role: 'reader', + scopeType: 'user', + scopeValue: 'alex@example.test', + ); + final changed = await client.changeAclRole('team', user, 'writer'); + expect(changed.role, 'writer'); + expect(requests.single.method, 'PATCH'); + expect( + requests.single.url.path, + endsWith('/acl/user%3Aalex%40example.test'), + ); + expect(jsonDecode(requests.single.body), {'role': 'writer'}); + await client.revokeAclUser('team', user); + expect(requests.last.method, 'DELETE'); + const owner = GoogleAclRule(id: 'owner', role: 'owner', scopeType: 'user'); + expect(() => client.revokeAclUser('team', owner), throwsArgumentError); + await expectLater( + client.changeAclRole('team', owner, 'reader'), + throwsArgumentError, + ); + expect(requests, hasLength(2)); + }); + test('event labels are retrieved from the selected calendar only', () async { + late http.Request captured; + final client = _client((request) { + captured = request; + return _json({ + 'labelProperties': { + 'eventLabels': [ + {'id': 'label-one', 'name': 'Review', 'backgroundColor': '#123abc'}, + ], + }, + }); + }); + final labels = await client.getEventLabels('team@example.com'); + expect(captured.url.path, '/calendar/v3/calendars/team%40example.com'); + expect(labels.single.id, 'label-one'); + expect(labels.single.backgroundColor, '#123abc'); + }); + + test('malformed event label metadata never appears empty', () async { + final client = _client((_) => _json({'labelProperties': {}})); + await expectLater(client.getEventLabels('calendar'), throwsFormatException); + }); + + test( + 'Google attachment mutation preserves fresh collection and conflict token', + () async { + final requests = []; + final client = _client((request) { + requests.add(request); + if (request.method == 'GET') { + return _json({ + 'id': 'event', + 'etag': '"v2"', + 'attachments': [ + { + 'fileUrl': 'https://drive.google.com/file/old', + 'title': 'Old', + 'fileId': 'opaque', + }, + ], + }); + } + return _json({ + 'id': 'event', + 'etag': '"v3"', + 'attachments': jsonDecode(request.body)['attachments'], + }); + }); + + await client.changeEventAttachmentReferences( + calendarId: 'calendar', + eventId: 'event', + addFileUrl: 'https://drive.google.com/file/new', + addTitle: 'New', + ); + + expect(requests.map((request) => request.method), ['GET', 'PATCH']); + expect(requests.last.url.queryParameters, { + 'supportsAttachments': 'true', + 'sendUpdates': 'none', + }); + expect(requests.last.headers['If-Match'], '"v2"'); + expect(jsonDecode(requests.last.body)['attachments'], [ + { + 'fileUrl': 'https://drive.google.com/file/old', + 'title': 'Old', + 'fileId': 'opaque', + }, + {'fileUrl': 'https://drive.google.com/file/new', 'title': 'New'}, + ]); + }, + ); + + test( + 'Google attachment mutation refuses malformed authoritative list', + () async { + var requests = 0; + final client = _client((request) { + requests++; + return _json({ + 'id': 'event', + 'etag': '"v2"', + 'attachments': {'unexpected': true}, + }); + }); + await expectLater( + client.changeEventAttachmentReferences( + calendarId: 'calendar', + eventId: 'event', + removeFileUrl: 'https://drive.google.com/file/old', + ), + throwsFormatException, + ); + expect(requests, 1); + }, + ); + + test( + 'Google attachment retry accepts an already-applied reference', + () async { + var requests = 0; + final client = _client((request) { + requests++; + return _json({ + 'id': 'event', + 'etag': '"v3"', + 'attachments': [ + {'fileUrl': 'https://drive.google.com/file/new', 'title': 'New'}, + ], + }); + }); + + final event = await client.changeEventAttachmentReferences( + calendarId: 'calendar', + eventId: 'event', + addFileUrl: 'https://drive.google.com/file/new', + addTitle: 'New', + ); + + expect(requests, 1); + expect(event.attachmentsJson, isNotEmpty); + }, + ); + + test( + 'Google attachment mutation reconciles uncertain PATCH response', + () async { + var requests = 0; + var applied = false; + final client = _client((request) { + requests++; + if (request.method == 'PATCH') { + applied = true; + return http.Response('{"error":{"message":"unknown"}}', 503); + } + return _json({ + 'id': 'event', + 'etag': applied ? '"v3"' : '"v2"', + 'attachments': applied + ? [ + {'fileUrl': 'https://drive.google.com/file/new'}, + ] + : [], + }); + }); + + final event = await client.changeEventAttachmentReferences( + calendarId: 'calendar', + eventId: 'event', + addFileUrl: 'https://drive.google.com/file/new', + ); + + expect(requests, 3); + expect(event.etagOrChangeKey, '"v3"'); + }, + ); + + test( + 'ordinary Google event patch does not replace cached attachments', + () async { + late http.Request captured; + final client = _client((request) { + captured = request; + return _json(_googleEventJson()); + }); + + await client.updateEvent( + calendarId: 'calendar@example.com', + eventId: 'event-1', + mutation: const CalendarEventMutation( + title: 'Updated title', + providerRaw: { + 'attachments': [ + {'fileUrl': 'https://drive.google.com/file/stale'}, + ], + }, + ), + ); + + expect(captured.method, 'PATCH'); + expect(jsonDecode(captured.body), isNot(contains('attachments'))); + expect( + captured.url.queryParameters, + isNot(contains('supportsAttachments')), + ); + }, + ); + + test( + 'label mutations opt into version 1 without altering color-only edits', + () async { + final requests = []; + final client = _client((request) { + requests.add(request); + return _json(_googleEventJson()); + }); + await client.updateEvent( + calendarId: 'calendar@example.com', + eventId: 'event-1', + mutation: const CalendarEventMutation(eventLabelId: 'label-1'), + ); + await client.updateEvent( + calendarId: 'calendar@example.com', + eventId: 'event-1', + mutation: const CalendarEventMutation(eventLabelId: ''), + ); + await client.updateEvent( + calendarId: 'calendar@example.com', + eventId: 'event-1', + mutation: const CalendarEventMutation(colorId: '5'), + ); + expect(requests[0].url.queryParameters['eventLabelVersion'], '1'); + expect(jsonDecode(requests[0].body)['eventLabelId'], 'label-1'); + expect(requests[1].url.queryParameters['eventLabelVersion'], '1'); + expect(jsonDecode(requests[1].body)['eventLabelId'], ''); + expect( + requests[2].url.queryParameters, + isNot(contains('eventLabelVersion')), + ); + expect(jsonDecode(requests[2].body), {'colorId': '5'}); + }, + ); + test( 'calendar color retry never repeats an acknowledged creation POST', () async { @@ -260,6 +763,44 @@ void main() { expect(requests[2].headers['if-match'], '"delete-version"'); }); + test( + 'H import occurrence lookup targets timed and all-day original starts', + () async { + final requests = []; + final client = _client((request) { + requests.add(request); + return _json({'items': []}); + }); + await client.findImportEventInstances( + calendarId: 'calendar@example.com', + recurringEventId: 'series-1', + originalStart: '2026-08-31T16:00:00.000Z', + ); + await client.findImportEventInstances( + calendarId: 'calendar@example.com', + recurringEventId: 'series-1', + originalStart: '2026-09-01', + ); + expect(requests, hasLength(2)); + expect( + requests.map((request) => request.url.queryParameters['originalStart']), + ['2026-08-31T16:00:00.000Z', '2026-09-01'], + ); + expect( + requests.every( + (request) => request.url.queryParameters['showDeleted'] == 'true', + ), + isTrue, + ); + expect( + requests.every( + (request) => !request.url.queryParameters.containsKey('timeMin'), + ), + isTrue, + ); + }, + ); + test('native event move sends destination and guest update policy', () async { late http.Request captured; final client = _client((request) { diff --git a/test/google_calendar/google_calendar_mapper_test.dart b/test/google_calendar/google_calendar_mapper_test.dart index 9d78cd08..bc65970f 100644 --- a/test/google_calendar/google_calendar_mapper_test.dart +++ b/test/google_calendar/google_calendar_mapper_test.dart @@ -4,6 +4,14 @@ import 'package:busymax/src/google_calendar/google_calendar_mapper.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { + test('Google event absence of attachment field is a loaded empty list', () { + final event = googleCalendarEventFromJson('cal', { + 'id': 'event', + 'summary': 'Planning', + }); + expect(event.attachmentsJson, isEmpty); + }); + test('Google create identity is stable and uses the valid ID alphabet', () { final first = googleCalendarCreateEventId( '00112233-4455-6677-8899-aabbccddeeff', @@ -76,4 +84,89 @@ void main() { '0123456789abcdef', ); }); + + test('Google status properties survive unrelated event edits', () { + const cases = { + 'focusTime': { + 'focusTimeProperties': { + 'autoDeclineMode': 'declineOnlyNewConflictingInvitations', + 'declineMessage': 'Heads down', + }, + }, + 'outOfOffice': { + 'outOfOfficeProperties': { + 'autoDeclineMode': 'declineAllConflictingInvitations', + }, + }, + 'workingLocation': { + 'workingLocationProperties': {'type': 'homeOffice'}, + }, + }; + for (final entry in cases.entries) { + final body = googleEventMutationToJson( + CalendarEventMutation( + title: 'Updated', + providerRaw: {'eventType': entry.key, ...entry.value}, + ), + ); + expect(body['eventType'], entry.key); + for (final property in entry.value.entries) { + expect(body[property.key], property.value); + } + } + }); + + test( + 'Google status creation and targeted property edits use native fields', + () { + for (final (type, key, properties) in [ + ( + 'focusTime', + 'focusTimeProperties', + { + 'autoDeclineMode': 'declineNone', + 'chatStatus': 'doNotDisturb', + }, + ), + ( + 'outOfOffice', + 'outOfOfficeProperties', + { + 'autoDeclineMode': 'declineOnlyNewConflictingInvitations', + }, + ), + ( + 'workingLocation', + 'workingLocationProperties', + { + 'type': 'customLocation', + 'customLocation': {'label': 'Site'}, + }, + ), + ]) { + final created = googleEventMutationToJson( + CalendarEventMutation( + eventType: type, + googleStatusProperties: properties, + ), + ); + expect(created['eventType'], type); + expect(created[key], properties); + final edited = googleEventMutationToJson( + CalendarEventMutation( + googleStatusEventTypeContext: type, + googleStatusProperties: properties, + ), + ); + expect(edited[key], properties); + expect(edited, isNot(contains('eventType'))); + } + expect( + () => googleEventMutationToJson( + const CalendarEventMutation(googleStatusProperties: {}), + ), + throwsFormatException, + ); + }, + ); } diff --git a/test/ical/ical_import_service_test.dart b/test/ical/ical_import_service_test.dart index 81577f65..34e4625e 100644 --- a/test/ical/ical_import_service_test.dart +++ b/test/ical/ical_import_service_test.dart @@ -1,12 +1,22 @@ import 'dart:convert'; import 'package:busymax/src/calendar_providers/calendar_mutation.dart'; +import 'package:busymax/src/calendar_providers/cloud_calendar_client.dart'; import 'package:busymax/src/db/app_database.dart'; import 'package:busymax/src/features/calendar/data/calendar_repository.dart'; +import 'package:busymax/src/features/schedule/presentation/cloud_calendar_series_export.dart'; +import 'package:busymax/src/features/sync/calendar_pending_ops_replayer.dart'; +import 'package:busymax/src/google_calendar/google_calendar_api_client.dart'; +import 'package:busymax/src/microsoft_calendar/microsoft_calendar_api_client.dart'; +import 'package:busymax/src/microsoft_calendar/microsoft_calendar_mapper.dart'; +import 'package:busymax/src/providers/busy_provider.dart'; import 'package:busymax/src/ical/ical_import_service.dart'; import 'package:drift/drift.dart' show Value; import 'package:drift/native.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:timezone/data/latest_all.dart' as time_zone_data; void main() { late AppDatabase database; @@ -31,6 +41,681 @@ void main() { tearDown(() async => database.close()); + test( + 'R1 exported Microsoft exception retains privacy, availability and categories in queued patch', + () async { + await _seedMicrosoftDestination(database); + time_zone_data.initializeTimeZones(); + final master = microsoftCalendarEventFromJson('source-calendar', { + 'id': 'source-master', + 'uid': 'r1-series', + 'subject': 'Master', + 'type': 'seriesMaster', + 'sensitivity': 'normal', + 'showAs': 'busy', + 'categories': ['A'], + 'start': {'dateTime': '2026-08-30T16:00:00', 'timeZone': 'UTC'}, + 'end': {'dateTime': '2026-08-30T17:00:00', 'timeZone': 'UTC'}, + 'recurrence': { + 'pattern': {'type': 'daily', 'interval': 1}, + 'range': { + 'type': 'numbered', + 'startDate': '2026-08-30', + 'numberOfOccurrences': 2, + 'recurrenceTimeZone': 'UTC', + }, + }, + }); + final exception = microsoftCalendarEventFromJson('source-calendar', { + 'id': 'source-exception', + 'uid': 'r1-series', + 'seriesMasterId': 'source-master', + 'originalStart': '2026-08-31T16:00:00Z', + 'subject': 'Moved', + 'sensitivity': 'private', + 'showAs': 'free', + 'categories': ['B'], + 'start': {'dateTime': '2026-08-31T18:00:00', 'timeZone': 'UTC'}, + 'end': {'dateTime': '2026-08-31T19:00:00', 'timeZone': 'UTC'}, + }); + final exported = cloudSeriesToICalendar( + master: master, + exceptions: [exception], + nowUtc: DateTime.utc(2026, 8, 29), + ); + expect(exported, contains('CLASS:PRIVATE')); + expect(exported, contains('TRANSP:TRANSPARENT')); + expect(exported, contains('CATEGORIES:B')); + final preview = importService.parsePreview(utf8.encode(exported)); + final destination = (await importService.writableDestinations()) + .singleWhere((source) => source.accountId == 'microsoft-account'); + expect( + (await importService.importPreview( + preview: preview, + destination: destination, + )).queued, + 1, + ); + final pending = await database.select(database.pendingOps).get(); + final request = + jsonDecode( + pending + .singleWhere( + (op) => op.operationType == 'event.importException', + ) + .requestJson, + ) + as Map; + expect(request['sensitivity'], 'private'); + expect(request['transparencyOrShowAs'], 'free'); + expect(request['categoriesJson'], ['B']); + final masterRemote = { + 'id': 'imported-master', + 'uid': 'r1-series', + 'subject': 'Master', + 'type': 'seriesMaster', + 'sensitivity': 'normal', + 'showAs': 'busy', + 'categories': ['A'], + 'start': {'dateTime': '2026-08-30T16:00:00', 'timeZone': 'UTC'}, + 'end': {'dateTime': '2026-08-30T17:00:00', 'timeZone': 'UTC'}, + }; + final occurrenceRemote = { + 'id': 'imported-occurrence', + 'uid': 'r1-series', + 'seriesMasterId': 'imported-master', + 'originalStart': '2026-08-31T16:00:00Z', + 'occurrenceId': 'oid-r1', + 'subject': 'Master', + 'sensitivity': 'normal', + 'showAs': 'busy', + 'categories': ['A'], + 'start': {'dateTime': '2026-08-31T16:00:00', 'timeZone': 'UTC'}, + 'end': {'dateTime': '2026-08-31T17:00:00', 'timeZone': 'UTC'}, + }; + final patches = >[]; + final client = MicrosoftCalendarApiClient( + httpClient: MockClient((httpRequest) async { + if (httpRequest.method == 'POST') { + return http.Response(jsonEncode(masterRemote), 201); + } + if (httpRequest.url.path.endsWith('/instances')) { + return http.Response( + jsonEncode({ + 'value': [occurrenceRemote], + }), + 200, + ); + } + if (httpRequest.method == 'GET' && + httpRequest.url.queryParameters.containsKey(r'$expand')) { + return http.Response( + jsonEncode({ + ...masterRemote, + 'exceptionOccurrences': [], + 'cancelledOccurrences': [], + }), + 200, + ); + } + if (httpRequest.method == 'PATCH') { + final patch = (jsonDecode(httpRequest.body) as Map) + .cast(); + patches.add(patch); + occurrenceRemote.addAll(patch); + } + return http.Response(jsonEncode(occurrenceRemote), 200); + }), + baseUri: Uri.parse('https://graph.microsoft.com/v1.0'), + responseTimeZone: 'UTC', + ); + final replayer = CalendarPendingOpsReplayer( + database: database, + client: client, + accountId: 'microsoft-account', + nowUtc: () => DateTime.utc(2026, 8, 29), + ); + await replayer.replayDueOps(); + await replayer.replayDueOps(); + expect(patches, hasLength(1)); + expect(patches.single['sensitivity'], 'private'); + expect(patches.single['showAs'], 'free'); + expect(patches.single['categories'], ['B']); + expect(masterRemote['sensitivity'], 'normal'); + expect(masterRemote['showAs'], 'busy'); + final reloaded = (await database.select(database.calendarEvents).get()) + .singleWhere( + (event) => event.providerEventId == 'imported-occurrence', + ); + expect(reloaded.visibility, 'private'); + expect(reloaded.transparencyOrShowAs, 'free'); + expect(jsonDecode(reloaded.categoriesJson!), ['B']); + }, + ); + + test( + 'R1 Google same-visibility exception retains transparency and omits categories', + () async { + final preview = importService.parsePreview( + utf8.encode( + _calendar(''' +BEGIN:VEVENT +UID:r1-google +DTSTART:20260830T160000Z +DTEND:20260830T170000Z +SUMMARY:Master +RRULE:FREQ=DAILY;COUNT=3 +CLASS:PRIVATE +TRANSP:TRANSPARENT +END:VEVENT +BEGIN:VEVENT +UID:r1-google +RECURRENCE-ID:20260831T160000Z +DTSTART:20260831T180000Z +DTEND:20260831T190000Z +SUMMARY:Override +CLASS:PRIVATE +TRANSP:OPAQUE +CATEGORIES:Do not import +END:VEVENT +BEGIN:VEVENT +UID:r1-google +RECURRENCE-ID:20260901T160000Z +DTSTART:20260901T160000Z +DTEND:20260901T170000Z +SUMMARY:Inherited +END:VEVENT +'''), + ), + ); + final report = await importService.importPreview( + preview: preview, + destination: (await importService.writableDestinations()).single, + ); + expect(report.queued, 1); + expect(report.fieldsIntentionallyOmitted, contains('categories')); + final requests = (await database.select(database.pendingOps).get()) + .where((op) => op.operationType == 'event.importException') + .map((op) => jsonDecode(op.requestJson) as Map) + .toList(); + expect(requests, hasLength(2)); + expect(requests.first['sensitivity'], 'private'); + expect(requests.first['transparencyOrShowAs'], 'opaque'); + expect(requests.first, isNot(contains('categoriesJson'))); + expect(requests.last, isNot(contains('sensitivity'))); + expect(requests.last, isNot(contains('transparencyOrShowAs'))); + }, + ); + + test( + 'D Google mixed recurrence privacy is rejected before creation', + () async { + for (final (masterClass, exceptionClass) in [ + ('PRIVATE', 'PUBLIC'), + ('PUBLIC', 'PRIVATE'), + ]) { + final preview = importService.parsePreview( + utf8.encode( + _calendar(''' +BEGIN:VEVENT +UID:d-google-$masterClass-$exceptionClass +DTSTART:20260830T160000Z +DTEND:20260830T170000Z +SUMMARY:Master +RRULE:FREQ=DAILY;COUNT=2 +CLASS:$masterClass +END:VEVENT +BEGIN:VEVENT +UID:d-google-$masterClass-$exceptionClass +RECURRENCE-ID:20260831T160000Z +DTSTART:20260831T160000Z +DTEND:20260831T170000Z +CLASS:$exceptionClass +END:VEVENT +'''), + ), + ); + final report = await importService.importPreview( + preview: preview, + destination: (await importService.writableDestinations()).single, + ); + expect(report.queued, 0); + expect(report.unsupportedRecurrenceSets, isNotEmpty); + expect(await database.select(database.pendingOps).get(), isEmpty); + } + }, + ); + + test( + 'R1 unsupported exception classification rejects the set before master creation', + () async { + final preview = importService.parsePreview( + utf8.encode( + _calendar(''' +BEGIN:VEVENT +UID:r1-unsupported +DTSTART:20260830T160000Z +DTEND:20260830T170000Z +SUMMARY:Master +RRULE:FREQ=DAILY;COUNT=2 +END:VEVENT +BEGIN:VEVENT +UID:r1-unsupported +RECURRENCE-ID:20260831T160000Z +DTSTART:20260831T160000Z +DTEND:20260831T170000Z +CLASS:SECRET +END:VEVENT +'''), + ), + ); + final report = await importService.importPreview( + preview: preview, + destination: (await importService.writableDestinations()).single, + ); + expect(report.queued, 0); + expect(report.unsupportedRecurrenceSets, hasLength(1)); + expect(await database.select(database.pendingOps).get(), isEmpty); + }, + ); + + for (final provider in [BusyProvider.google, BusyProvider.microsoft]) { + test( + 'R2 $provider zero-minute master and exception alarms stay explicit', + () async { + if (provider == BusyProvider.microsoft) { + await _seedMicrosoftDestination(database); + } + final preview = importService.parsePreview( + utf8.encode( + _calendar(''' +BEGIN:VEVENT +UID:r2-zero-$provider +DTSTART:20260830T160000Z +DTEND:20260830T170000Z +SUMMARY:Master +RRULE:FREQ=DAILY;COUNT=2 +BEGIN:VALARM +ACTION:DISPLAY +TRIGGER:-PT0M +END:VALARM +END:VEVENT +BEGIN:VEVENT +UID:r2-zero-$provider +RECURRENCE-ID:20260831T160000Z +DTSTART:20260831T180000Z +DTEND:20260831T190000Z +SUMMARY:Moved +BEGIN:VALARM +ACTION:DISPLAY +TRIGGER:PT0M +END:VALARM +END:VEVENT +'''), + ), + ); + expect( + preview.fieldsThatWillBeOmitted, + isNot(contains('unsupported alarms')), + ); + final destination = (await importService.writableDestinations()) + .singleWhere((source) => source.provider == provider); + expect( + (await importService.importPreview( + preview: preview, + destination: destination, + )).queued, + 1, + ); + final requests = (await database.select(database.pendingOps).get()) + .map((op) => jsonDecode(op.requestJson) as Map) + .toList(); + final expected = provider == BusyProvider.microsoft + ? {'isReminderOn': true, 'reminderMinutesBeforeStart': 0} + : { + 'useDefault': false, + 'overrides': [ + {'method': 'popup', 'minutes': 0}, + ], + }; + expect(requests.first['remindersJson'], expected); + expect(requests.last['remindersJson'], expected); + final isMicrosoft = provider == BusyProvider.microsoft; + final masterRemote = { + 'id': 'r2-master', + if (isMicrosoft) 'subject': 'Master' else 'summary': 'Master', + if (isMicrosoft) + 'type': 'seriesMaster' + else + 'iCalUID': 'r2-zero-$provider', + 'start': isMicrosoft + ? {'dateTime': '2026-08-30T16:00:00', 'timeZone': 'UTC'} + : {'dateTime': '2026-08-30T16:00:00Z'}, + 'end': isMicrosoft + ? {'dateTime': '2026-08-30T17:00:00', 'timeZone': 'UTC'} + : {'dateTime': '2026-08-30T17:00:00Z'}, + }; + final occurrenceRemote = { + 'id': 'r2-occurrence', + if (isMicrosoft) + 'seriesMasterId': 'r2-master' + else + 'recurringEventId': 'r2-master', + if (isMicrosoft) + 'originalStart': '2026-08-31T16:00:00Z' + else + 'originalStartTime': {'dateTime': '2026-08-31T16:00:00Z'}, + if (isMicrosoft) 'subject': 'Master' else 'summary': 'Master', + 'start': isMicrosoft + ? {'dateTime': '2026-08-31T16:00:00', 'timeZone': 'UTC'} + : {'dateTime': '2026-08-31T16:00:00Z'}, + 'end': isMicrosoft + ? {'dateTime': '2026-08-31T17:00:00', 'timeZone': 'UTC'} + : {'dateTime': '2026-08-31T17:00:00Z'}, + if (isMicrosoft) 'occurrenceId': 'r2-occurrence-id', + }; + final patches = >[]; + final httpClient = MockClient((httpRequest) async { + if (httpRequest.method == 'POST') { + masterRemote.addAll( + (jsonDecode(httpRequest.body) as Map).cast(), + ); + return http.Response( + jsonEncode(masterRemote), + isMicrosoft ? 201 : 200, + ); + } + if (httpRequest.url.path.endsWith('/instances')) { + return http.Response( + jsonEncode( + isMicrosoft + ? { + 'value': [occurrenceRemote], + } + : { + 'items': [occurrenceRemote], + }, + ), + 200, + ); + } + if (httpRequest.method == 'GET' && + isMicrosoft && + httpRequest.url.queryParameters.containsKey(r'$expand')) { + return http.Response( + jsonEncode({ + ...masterRemote, + 'exceptionOccurrences': [], + 'cancelledOccurrences': [], + }), + 200, + ); + } + if (httpRequest.method == 'GET' && !isMicrosoft) { + return http.Response(jsonEncode({'items': []}), 200); + } + if (httpRequest.method == 'PATCH') { + final patch = (jsonDecode(httpRequest.body) as Map) + .cast(); + patches.add(patch); + occurrenceRemote.addAll(patch); + } + return http.Response(jsonEncode(occurrenceRemote), 200); + }); + final CloudCalendarClient client = isMicrosoft + ? MicrosoftCalendarApiClient( + httpClient: httpClient, + baseUri: Uri.parse('https://graph.microsoft.com/v1.0'), + responseTimeZone: 'UTC', + ) + : GoogleCalendarApiClient( + httpClient: httpClient, + baseUri: Uri.parse('https://www.googleapis.com'), + ); + final replayer = CalendarPendingOpsReplayer( + database: database, + client: client, + accountId: destination.accountId, + nowUtc: () => DateTime.utc(2026, 8, 29), + ); + await replayer.replayDueOps(); + await replayer.replayDueOps(); + expect(patches, hasLength(1)); + if (isMicrosoft) { + expect(patches.single['isReminderOn'], true); + expect(patches.single['reminderMinutesBeforeStart'], 0); + expect(masterRemote['isReminderOn'], true); + expect(masterRemote['reminderMinutesBeforeStart'], 0); + } else { + expect(patches.single['reminders'], expected); + expect(masterRemote['reminders'], expected); + } + final reloaded = (await database.select(database.calendarEvents).get()) + .singleWhere((event) => event.providerEventId == 'r2-occurrence'); + expect(jsonDecode(reloaded.remindersJson!), expected); + }, + ); + } + + test( + 'R2 zero-offset alarms are supported without accepting after-start or end-relative alarms', + () { + IcalImportPreview previewFor(String trigger) => + importService.parsePreview( + utf8.encode( + _calendar(''' +BEGIN:VEVENT +UID:r2-trigger +DTSTART:20260830T160000Z +DTEND:20260830T170000Z +SUMMARY:Alarm +BEGIN:VALARM +ACTION:DISPLAY +$trigger +END:VALARM +END:VEVENT +'''), + ), + ); + for (final trigger in [ + 'TRIGGER:-PT0M', + 'TRIGGER:PT0M', + 'TRIGGER:-PT10M', + ]) { + expect( + previewFor(trigger).fieldsThatWillBeOmitted, + isNot(contains('unsupported alarms')), + ); + } + for (final trigger in [ + 'TRIGGER:PT10M', + 'TRIGGER;RELATED=END:-PT0M', + 'TRIGGER:invalid', + ]) { + expect( + previewFor(trigger).fieldsThatWillBeOmitted, + contains('unsupported alarms'), + ); + } + }, + ); + + test('Microsoft PUBLIC import replays as normal sensitivity', () async { + await _seedMicrosoftDestination(database); + time_zone_data.initializeTimeZones(); + final exported = cloudSeriesToICalendar( + master: microsoftCalendarEventFromJson('source-calendar', { + 'id': 'source-series', + 'uid': 'public-import', + 'subject': 'Public import', + 'type': 'seriesMaster', + 'sensitivity': 'normal', + 'start': {'dateTime': '2026-08-30T16:00:00', 'timeZone': 'UTC'}, + 'end': {'dateTime': '2026-08-30T17:00:00', 'timeZone': 'UTC'}, + 'recurrence': { + 'pattern': {'type': 'daily', 'interval': 1}, + 'range': { + 'type': 'numbered', + 'startDate': '2026-08-30', + 'numberOfOccurrences': 2, + 'recurrenceTimeZone': 'UTC', + }, + }, + }), + exceptions: const [], + nowUtc: DateTime.utc(2026, 8, 29), + ); + expect(exported, contains('CLASS:PUBLIC')); + final preview = importService.parsePreview(utf8.encode(exported)); + final destination = (await importService.writableDestinations()) + .singleWhere((source) => source.accountId == 'microsoft-account'); + expect( + (await importService.importPreview( + preview: preview, + destination: destination, + )).queued, + 1, + ); + final requests = []; + final client = MicrosoftCalendarApiClient( + httpClient: MockClient((request) async { + requests.add(request); + return http.Response( + jsonEncode({ + 'id': 'remote-public', + 'subject': 'Public import', + 'sensitivity': 'normal', + 'start': {'dateTime': '2026-08-30T16:00:00', 'timeZone': 'UTC'}, + 'end': {'dateTime': '2026-08-30T17:00:00', 'timeZone': 'UTC'}, + }), + 201, + ); + }), + baseUri: Uri.parse('https://graph.microsoft.com/v1.0'), + responseTimeZone: 'UTC', + ); + await CalendarPendingOpsReplayer( + database: database, + client: client, + accountId: 'microsoft-account', + nowUtc: () => DateTime.utc(2026, 8, 29), + ).replayDueOps(); + final post = requests.singleWhere((request) => request.method == 'POST'); + expect((jsonDecode(post.body) as Map)['sensitivity'], 'normal'); + final detail = (await calendarRepository.loadEventDetail( + CalendarRepository.eventId( + accountId: 'microsoft-account', + provider: BusyProvider.microsoft, + providerCalendarId: 'calendar', + providerEventId: 'remote-public', + ), + ))!; + expect(detail.visibility, 'normal'); + }); + + for (final scenario in const [ + (classification: 'PRIVATE', expected: 'private'), + (classification: 'CONFIDENTIAL', expected: 'confidential'), + (classification: '', expected: null), + ]) { + test( + 'Microsoft ${scenario.classification.isEmpty ? 'absent' : scenario.classification} classification queues ${scenario.expected}', + () async { + await _seedMicrosoftDestination(database); + final classification = scenario.classification.isEmpty + ? '' + : 'CLASS:${scenario.classification}\n'; + final preview = importService.parsePreview( + utf8.encode( + _calendar(''' +BEGIN:VEVENT +UID:classification-${scenario.classification} +DTSTART:20260830T160000Z +DTEND:20260830T170000Z +SUMMARY:Classification +${classification}END:VEVENT +'''), + ), + ); + final destination = (await importService.writableDestinations()) + .singleWhere((source) => source.accountId == 'microsoft-account'); + expect( + (await importService.importPreview( + preview: preview, + destination: destination, + )).queued, + 1, + ); + final request = + jsonDecode( + (await database.select(database.pendingOps).get()) + .single + .requestJson, + ) + as Map; + expect(request['sensitivity'], scenario.expected); + }, + ); + } + + test( + 'Google PUBLIC classification keeps visibility and Microsoft rejects unknown', + () async { + final googlePreview = importService.parsePreview( + utf8.encode( + _calendar(''' +BEGIN:VEVENT +UID:google-public +DTSTART:20260830T160000Z +DTEND:20260830T170000Z +SUMMARY:Google public +CLASS:PUBLIC +END:VEVENT +'''), + ), + ); + final google = (await importService.writableDestinations()).singleWhere( + (source) => source.accountId == 'google-account', + ); + expect( + (await importService.importPreview( + preview: googlePreview, + destination: google, + )).queued, + 1, + ); + expect( + jsonDecode( + (await database.select(database.pendingOps).get()).single.requestJson, + )['visibility'], + 'public', + ); + await _seedMicrosoftDestination(database); + final unsupported = importService.parsePreview( + utf8.encode( + _calendar(''' +BEGIN:VEVENT +UID:unknown-class +DTSTART:20260830T160000Z +DTEND:20260830T170000Z +SUMMARY:Unknown +CLASS:SECRET +END:VEVENT +'''), + ), + ); + final microsoft = (await importService.writableDestinations()) + .singleWhere((source) => source.accountId == 'microsoft-account'); + final report = await importService.importPreview( + preview: unsupported, + destination: microsoft, + ); + expect(report.queued, 0); + expect(report.unsupportedRecurrenceSets, hasLength(1)); + expect((await database.select(database.pendingOps).get()), hasLength(1)); + }, + ); + test( 'preview performs no mutation and reports omitted scheduling fields', () { @@ -124,6 +809,7 @@ END:VEVENT final operation = await database.select(database.pendingOps).getSingle(); final request = jsonDecode(operation.requestJson) as Map; expect(request[calendarEventGuestUpdatePolicyKey], 'doNotSend'); + expect(request[calendarEventImportIcalUidKey], 'recurring-import'); expect(request['attendeesJson'], isNull); expect(request, isNot(contains('organizer'))); expect(request, isNot(contains('conferenceJson'))); @@ -155,7 +841,74 @@ END:VEVENT ); test( - 'skips a whole recurrence set rather than dropping an exception', + 'Google import replays through private-copy endpoint with iCalUID', + () async { + final preview = importService.parsePreview( + utf8.encode( + _calendar(''' +BEGIN:VEVENT +UID:private-copy@example.test +DTSTART:20260830T160000Z +DTEND:20260830T170000Z +SUMMARY:Imported +ATTENDEE:mailto:guest@example.test +END:VEVENT +'''), + ), + ); + await importService.importPreview( + preview: preview, + destination: (await importService.writableDestinations()).single, + ); + final requests = []; + final client = GoogleCalendarApiClient( + httpClient: MockClient((request) async { + requests.add(request); + if (request.method == 'GET') { + return http.Response(jsonEncode({'items': []}), 200); + } + return http.Response( + jsonEncode({ + 'id': 'provider-event-id', + 'iCalUID': 'private-copy@example.test', + 'summary': 'Imported', + 'start': {'dateTime': '2026-08-30T16:00:00Z'}, + 'end': {'dateTime': '2026-08-30T17:00:00Z'}, + }), + 200, + ); + }), + baseUri: Uri.parse('https://www.googleapis.com'), + ); + await CalendarPendingOpsReplayer( + database: database, + client: client, + accountId: 'google-account', + nowUtc: () => DateTime.utc(2026, 8, 29), + ).replayDueOps(); + expect(requests.map((request) => request.method), ['GET', 'POST']); + expect( + requests.last.url.path, + '/calendar/v3/calendars/primary/events/import', + ); + expect(requests.last.url.queryParameters, { + 'supportsAttachments': 'true', + }); + final body = jsonDecode(requests.last.body) as Map; + expect(body['iCalUID'], 'private-copy@example.test'); + expect(body, isNot(contains('id'))); + expect(body, isNot(contains('attendees'))); + expect(await database.select(database.pendingOps).get(), isEmpty); + expect( + (await database.select(database.calendarEvents).getSingle()) + .providerEventId, + 'provider-event-id', + ); + }, + ); + + test( + 'replays a moved recurrence exception without sending invitations', () async { final preview = importService.parsePreview( utf8.encode( @@ -166,6 +919,8 @@ DTSTART:20260830T160000Z DTEND:20260830T170000Z SUMMARY:Master RRULE:FREQ=DAILY;COUNT=2 +CLASS:PRIVATE +TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT UID:with-exception @@ -173,6 +928,9 @@ RECURRENCE-ID:20260831T160000Z DTSTART:20260831T180000Z DTEND:20260831T190000Z SUMMARY:Moved +CLASS:PRIVATE +TRANSP:OPAQUE +CATEGORIES:Not imported END:VEVENT '''), ), @@ -183,18 +941,540 @@ END:VEVENT destination: (await importService.writableDestinations()).single, ); - expect(report.queued, 0); - expect(report.unsupportedRecurrenceSets, hasLength(1)); - expect(report.unsupportedRecurrenceSets.single.uid, 'with-exception'); - expect( - report.unsupportedRecurrenceSets.single.reason, - contains('Detached'), + expect(report.queued, 1); + expect(report.unsupportedRecurrenceSets, isEmpty); + expect(await database.select(database.pendingOps).get(), hasLength(2)); + final requests = []; + final remoteOccurrence = { + 'id': 'occurrence-1', + 'recurringEventId': 'master-1', + 'originalStartTime': {'dateTime': '2026-08-31T16:00:00Z'}, + 'start': {'dateTime': '2026-08-31T16:00:00Z'}, + 'end': {'dateTime': '2026-08-31T17:00:00Z'}, + 'visibility': 'private', + 'transparency': 'transparent', + }; + final client = GoogleCalendarApiClient( + httpClient: MockClient((request) async { + requests.add(request); + if (request.method == 'GET' && + request.url.path.endsWith('/instances')) { + return http.Response( + jsonEncode({ + 'items': [remoteOccurrence], + }), + 200, + ); + } + if (request.method == 'GET') { + return http.Response(jsonEncode({'items': []}), 200); + } + if (request.method == 'PATCH') { + remoteOccurrence.addAll( + (jsonDecode(request.body) as Map).cast(), + ); + return http.Response(jsonEncode(remoteOccurrence), 200); + } + return http.Response( + jsonEncode({ + 'id': request.method == 'POST' ? 'master-1' : 'occurrence-1', + 'recurringEventId': request.method == 'POST' ? null : 'master-1', + 'originalStartTime': request.method == 'POST' + ? null + : {'dateTime': '2026-08-31T16:00:00Z'}, + 'summary': request.method == 'POST' ? 'Master' : 'Moved', + 'start': { + 'dateTime': request.method == 'POST' + ? '2026-08-30T16:00:00Z' + : '2026-08-31T18:00:00Z', + }, + 'end': { + 'dateTime': request.method == 'POST' + ? '2026-08-30T17:00:00Z' + : '2026-08-31T19:00:00Z', + }, + }), + 200, + ); + }), + baseUri: Uri.parse('https://www.googleapis.com'), ); - expect(await database.select(database.calendarEvents).get(), isEmpty); + final replayer = CalendarPendingOpsReplayer( + database: database, + client: client, + accountId: 'google-account', + nowUtc: () => DateTime.utc(2026, 8, 29), + ); + await replayer.replayDueOps(); + await replayer.replayDueOps(); expect(await database.select(database.pendingOps).get(), isEmpty); + final patch = requests.singleWhere((r) => r.method == 'PATCH'); + expect( + patch.url.path, + '/calendar/v3/calendars/primary/events/occurrence-1', + ); + expect(patch.url.queryParameters['sendUpdates'], 'none'); + expect((jsonDecode(patch.body) as Map)['summary'], 'Moved'); + expect((jsonDecode(patch.body) as Map)['visibility'], 'private'); + expect((jsonDecode(patch.body) as Map)['transparency'], 'opaque'); + expect(patch.body, isNot(contains('categories'))); + final reloaded = (await database.select(database.calendarEvents).get()) + .singleWhere((event) => event.providerEventId == 'occurrence-1'); + expect(reloaded.visibility, 'private'); + expect(reloaded.transparencyOrShowAs, 'opaque'); }, ); + test('Microsoft import replays a moved instance through Graph', () async { + await database + .into(database.accounts) + .insert( + AccountsCompanion.insert( + id: 'microsoft-account', + provider: 'microsoft', + authority: 'https://login.microsoftonline.com', + providerAccountId: 'user', + credentialKind: 'oauth', + authState: const Value('signed_in'), + calendarsEnabled: const Value(true), + tasksEnabled: const Value(false), + grantedScopes: const Value('Calendars.ReadWrite'), + createdAtUtc: _now, + updatedAtUtc: _now, + ), + ); + await database + .into(database.calendarSources) + .insert( + CalendarSourcesCompanion.insert( + id: 'microsoft-account|microsoft|calendar', + accountId: 'microsoft-account', + provider: 'microsoft', + providerCalendarId: 'calendar', + summary: 'Calendar', + accessRole: const Value('owner'), + createdAtLocal: 1, + updatedAtLocal: 1, + ), + ); + final preview = importService.parsePreview( + utf8.encode( + _calendar(''' +BEGIN:VEVENT +UID:microsoft-series +DTSTART:20260830T160000Z +DTEND:20260830T170000Z +SUMMARY:Master +RRULE:FREQ=DAILY;COUNT=2 +END:VEVENT +BEGIN:VEVENT +UID:microsoft-series +RECURRENCE-ID:20260831T160000Z +DTSTART:20260831T180000Z +DTEND:20260831T190000Z +SUMMARY:Moved +END:VEVENT +'''), + ), + ); + final destination = (await importService.writableDestinations()) + .singleWhere((source) => source.accountId == 'microsoft-account'); + final report = await importService.importPreview( + preview: preview, + destination: destination, + ); + expect(report.queued, 1); + final requests = []; + Map graphEvent(String id, String title, String start) => { + 'id': id, + 'subject': title, + 'start': {'dateTime': start, 'timeZone': 'UTC'}, + 'end': {'dateTime': '2026-08-31T19:00:00', 'timeZone': 'UTC'}, + }; + final client = MicrosoftCalendarApiClient( + httpClient: MockClient((request) async { + requests.add(request); + if (request.method == 'POST') { + return http.Response( + jsonEncode( + graphEvent('master-ms', 'Master', '2026-08-30T16:00:00'), + ), + 201, + ); + } + if (request.url.path.endsWith('/instances')) { + return http.Response( + jsonEncode({ + 'value': [ + { + ...graphEvent( + 'occurrence-ms', + 'Master', + '2026-08-31T16:00:00', + ), + 'seriesMasterId': 'master-ms', + 'originalStart': '2026-08-31T16:00:00Z', + 'occurrenceId': 'oid-ms', + }, + ], + }), + 200, + ); + } + if (request.url.queryParameters.containsKey(r'$expand')) { + return http.Response( + jsonEncode({ + ...graphEvent('master-ms', 'Master', '2026-08-30T16:00:00'), + 'exceptionOccurrences': [], + 'cancelledOccurrences': [], + }), + 200, + ); + } + return http.Response( + jsonEncode({ + ...graphEvent('occurrence-ms', 'Moved', '2026-08-31T18:00:00'), + 'seriesMasterId': 'master-ms', + 'originalStart': '2026-08-31T16:00:00Z', + }), + 200, + ); + }), + baseUri: Uri.parse('https://graph.microsoft.com/v1.0'), + responseTimeZone: 'UTC', + ); + final replayer = CalendarPendingOpsReplayer( + database: database, + client: client, + accountId: 'microsoft-account', + nowUtc: () => DateTime.utc(2026, 8, 29), + ); + await replayer.replayDueOps(); + await replayer.replayDueOps(); + expect( + (await database.select(database.pendingOps).get()).where( + (row) => row.accountId == 'microsoft-account', + ), + isEmpty, + ); + final patch = requests.singleWhere((request) => request.method == 'PATCH'); + expect(patch.url.path, '/v1.0/me/calendars/calendar/events/occurrence-ms'); + expect((jsonDecode(patch.body) as Map)['subject'], 'Moved'); + expect(patch.body, isNot(contains('attendees'))); + }); + + for (final scenario in const [ + ( + zone: 'Pacific Standard Time', + master: '2026-08-30', + original: '2026-08-31', + moved: '2026-09-02', + originalUtc: '2026-08-31T07:00:00Z', + cancelled: false, + transientPatchFailure: false, + ), + ( + zone: 'Tokyo Standard Time', + master: '2026-08-30', + original: '2026-08-31', + moved: '2026-09-02', + originalUtc: '2026-08-30T15:00:00Z', + cancelled: false, + transientPatchFailure: false, + ), + ( + zone: 'Pacific Standard Time', + master: '2026-03-07', + original: '2026-03-08', + moved: '2026-03-10', + originalUtc: '2026-03-08T08:00:00Z', + cancelled: false, + transientPatchFailure: false, + ), + ( + zone: 'Tokyo Standard Time', + master: '2026-08-30', + original: '2026-08-31', + moved: '2026-08-31', + originalUtc: '2026-08-30T15:00:00Z', + cancelled: true, + transientPatchFailure: false, + ), + ( + zone: 'Pacific Standard Time', + master: '2026-08-30', + original: '2026-08-31', + moved: '2026-09-02', + originalUtc: '2026-08-31T07:00:00Z', + cancelled: false, + transientPatchFailure: true, + ), + ]) { + test( + 'Microsoft all-day import ${scenario.cancelled ? 'cancellation' : 'move'} matches Graph UTC originalStart in ${scenario.zone} on ${scenario.original}${scenario.transientPatchFailure ? ' after retry' : ''}', + () async { + await database + .into(database.accounts) + .insert( + AccountsCompanion.insert( + id: 'microsoft-account', + provider: 'microsoft', + authority: 'https://login.microsoftonline.com', + providerAccountId: 'user', + credentialKind: 'oauth', + authState: const Value('signed_in'), + calendarsEnabled: const Value(true), + tasksEnabled: const Value(false), + grantedScopes: const Value('Calendars.ReadWrite'), + createdAtUtc: _now, + updatedAtUtc: _now, + ), + ); + await database + .into(database.calendarSources) + .insert( + CalendarSourcesCompanion.insert( + id: 'microsoft-account|microsoft|calendar', + accountId: 'microsoft-account', + provider: 'microsoft', + providerCalendarId: 'calendar', + summary: 'Calendar', + accessRole: const Value('owner'), + timeZone: Value(scenario.zone), + createdAtLocal: 1, + updatedAtLocal: 1, + ), + ); + final masterEnd = DateTime.parse( + scenario.master, + ).add(const Duration(days: 1)).toIso8601String().substring(0, 10); + final originalEnd = DateTime.parse( + scenario.original, + ).add(const Duration(days: 1)).toIso8601String().substring(0, 10); + final movedEnd = DateTime.parse( + scenario.moved, + ).add(const Duration(days: 1)).toIso8601String().substring(0, 10); + String basic(String date) => date.replaceAll('-', ''); + final preview = importService.parsePreview( + utf8.encode( + _calendar(''' +BEGIN:VEVENT +UID:all-day-ms +DTSTART;VALUE=DATE:${basic(scenario.master)} +DTEND;VALUE=DATE:${basic(masterEnd)} +SUMMARY:Master +RRULE:FREQ=DAILY;COUNT=2 +END:VEVENT +BEGIN:VEVENT +UID:all-day-ms +RECURRENCE-ID;VALUE=DATE:${basic(scenario.original)} +${scenario.cancelled ? 'STATUS:CANCELLED' : 'DTSTART;VALUE=DATE:${basic(scenario.moved)}\nDTEND;VALUE=DATE:${basic(movedEnd)}\nSUMMARY:Moved'} +END:VEVENT +'''), + ), + ); + final destination = (await importService.writableDestinations()) + .singleWhere((source) => source.accountId == 'microsoft-account'); + expect( + (await importService.importPreview( + preview: preview, + destination: destination, + )).queued, + 1, + ); + final requests = []; + Map event(String id, String start) => { + 'id': id, + 'subject': 'Master', + 'isAllDay': true, + 'start': {'dateTime': start, 'timeZone': scenario.zone}, + 'end': { + 'dateTime': '${originalEnd}T00:00:00', + 'timeZone': scenario.zone, + }, + }; + final client = MicrosoftCalendarApiClient( + httpClient: MockClient((request) async { + requests.add(request); + if (request.method == 'POST') { + return http.Response( + jsonEncode(event('master-ms', '${scenario.master}T00:00:00')), + 201, + ); + } + if (request.method == 'DELETE') { + return http.Response('', 204); + } + if (request.method == 'PATCH' && + scenario.transientPatchFailure && + requests.where((entry) => entry.method == 'PATCH').length == + 1) { + return http.Response('temporary failure', 503); + } + if (request.url.path.endsWith('/instances')) { + return http.Response( + jsonEncode({ + 'value': [ + { + ...event( + 'occurrence-ms', + '${scenario.original}T00:00:00', + ), + 'seriesMasterId': 'master-ms', + 'originalStart': scenario.originalUtc, + 'occurrenceId': 'oid-ms', + }, + ], + }), + 200, + ); + } + if (request.url.queryParameters.containsKey(r'$expand')) { + return http.Response( + jsonEncode({ + ...event('master-ms', '${scenario.master}T00:00:00'), + 'exceptionOccurrences': [], + 'cancelledOccurrences': [], + }), + 200, + ); + } + return http.Response( + jsonEncode({ + ...event('occurrence-ms', '${scenario.moved}T00:00:00'), + 'seriesMasterId': 'master-ms', + 'originalStart': scenario.originalUtc, + }), + 200, + ); + }), + baseUri: Uri.parse('https://graph.microsoft.com/v1.0'), + responseTimeZone: 'UTC', + ); + final replayer = CalendarPendingOpsReplayer( + database: database, + client: client, + accountId: 'microsoft-account', + nowUtc: () => DateTime.utc(2026, 8, 29), + ); + await replayer.replayDueOps(); + await replayer.replayDueOps(); + if (scenario.transientPatchFailure) { + final pending = await database.select(database.pendingOps).get(); + expect(pending, hasLength(1)); + await database.pendingOpsDao.retryNow( + pending.single.id, + DateTime.utc(2026, 8, 29), + ); + } + await replayer.replayDueOps(); + expect( + requests.where((request) => request.method == 'PATCH'), + hasLength( + scenario.cancelled + ? 0 + : scenario.transientPatchFailure + ? 2 + : 1, + ), + ); + expect( + requests.where((request) => request.method == 'DELETE'), + hasLength(scenario.cancelled ? 1 : 0), + ); + expect( + requests.where((request) => request.method == 'POST'), + hasLength(1), + ); + expect(await database.select(database.pendingOps).get(), isEmpty); + }, + ); + } + + test('Google import persists a cancelled occurrence identity', () async { + final preview = importService.parsePreview( + utf8.encode( + _calendar(''' +BEGIN:VEVENT +UID:cancelled-series +DTSTART:20260830T160000Z +DTEND:20260830T170000Z +SUMMARY:Master +RRULE:FREQ=DAILY;COUNT=2 +END:VEVENT +BEGIN:VEVENT +UID:cancelled-series +RECURRENCE-ID:20260831T160000Z +STATUS:CANCELLED +END:VEVENT +'''), + ), + ); + final report = await importService.importPreview( + preview: preview, + destination: (await importService.writableDestinations()).single, + ); + expect(report.queued, 1); + var deleted = false; + final requests = []; + final client = GoogleCalendarApiClient( + httpClient: MockClient((request) async { + requests.add(request); + if (request.method == 'DELETE') { + deleted = true; + return http.Response('', 204); + } + if (request.url.path.endsWith('/instances')) { + return http.Response( + jsonEncode({ + 'items': [ + { + 'id': 'occurrence-2', + 'recurringEventId': 'master-2', + 'originalStartTime': {'dateTime': '2026-08-31T16:00:00Z'}, + 'status': deleted ? 'cancelled' : 'confirmed', + 'start': {'dateTime': '2026-08-31T16:00:00Z'}, + 'end': {'dateTime': '2026-08-31T17:00:00Z'}, + }, + ], + }), + 200, + ); + } + if (request.method == 'GET') { + return http.Response(jsonEncode({'items': []}), 200); + } + return http.Response( + jsonEncode({ + 'id': 'master-2', + 'summary': 'Master', + 'start': {'dateTime': '2026-08-30T16:00:00Z'}, + 'end': {'dateTime': '2026-08-30T17:00:00Z'}, + }), + 200, + ); + }), + baseUri: Uri.parse('https://www.googleapis.com'), + ); + final replayer = CalendarPendingOpsReplayer( + database: database, + client: client, + accountId: 'google-account', + nowUtc: () => DateTime.utc(2026, 8, 29), + ); + await replayer.replayDueOps(); + await replayer.replayDueOps(); + expect( + requests.where((request) => request.method == 'DELETE'), + hasLength(1), + ); + expect(await database.select(database.pendingOps).get(), isEmpty); + final occurrence = (await database.select(database.calendarEvents).get()) + .singleWhere((row) => row.providerEventId == 'occurrence-2'); + expect(occurrence.isCancelled, isTrue); + expect(occurrence.providerOriginalStartKey, '2026-08-31T16:00:00Z'); + }); + test( 'skips an embedded custom timezone the destination cannot carry', () async { @@ -310,7 +1590,7 @@ END:VEVENT expect( (await importService.writableDestinations()).map((source) => source.id), - ['google-calendar'], + ['google-account|google|primary'], ); }); } @@ -336,7 +1616,7 @@ Future _seedAccountAndSource(AppDatabase database) async { .into(database.calendarSources) .insert( CalendarSourcesCompanion.insert( - id: 'google-calendar', + id: 'google-account|google|primary', accountId: 'google-account', provider: 'google', providerCalendarId: 'primary', @@ -348,6 +1628,40 @@ Future _seedAccountAndSource(AppDatabase database) async { ); } +Future _seedMicrosoftDestination(AppDatabase database) async { + await database + .into(database.accounts) + .insert( + AccountsCompanion.insert( + id: 'microsoft-account', + provider: 'microsoft', + authority: 'https://login.microsoftonline.com', + providerAccountId: 'user', + credentialKind: 'oauth', + authState: const Value('signed_in'), + calendarsEnabled: const Value(true), + tasksEnabled: const Value(false), + grantedScopes: const Value('Calendars.ReadWrite'), + createdAtUtc: _now, + updatedAtUtc: _now, + ), + ); + await database + .into(database.calendarSources) + .insert( + CalendarSourcesCompanion.insert( + id: 'microsoft-account|microsoft|calendar', + accountId: 'microsoft-account', + provider: 'microsoft', + providerCalendarId: 'calendar', + summary: 'Calendar', + accessRole: const Value('owner'), + createdAtLocal: 1, + updatedAtLocal: 1, + ), + ); +} + const _now = '2026-08-29T00:00:00.000Z'; String _calendar(String body) => diff --git a/test/ical/ical_ingestion_test.dart b/test/ical/ical_ingestion_test.dart index 800ae9d5..96f731b7 100644 --- a/test/ical/ical_ingestion_test.dart +++ b/test/ical/ical_ingestion_test.dart @@ -7,6 +7,78 @@ import 'package:flutter_test/flutter_test.dart'; void main() { group('shared iCalendar ingestion', () { + test( + 'CalDAV attachment URI is projected while original property stays intact', + () { + final result = IcalIngestion.parseString( + _calendar(''' +BEGIN:VEVENT +UID:attached +DTSTART:20260830T160000Z +DTEND:20260830T170000Z +SUMMARY:Agenda +ATTACH;FMTTYPE=application/pdf:https://cloud.example.test/file.pdf +END:VEVENT +'''), + policy: IcalIngestionPolicy.webCal, + ); + final event = IcalEventProjector() + .project( + result.recurrenceSets.single, + rangeStartUtc: DateTime.utc(2026, 8, 1), + rangeEndUtc: DateTime.utc(2026, 9, 1), + transport: 'caldav', + ) + .single; + expect(jsonDecode(event.attachmentsJson), [ + 'https://cloud.example.test/file.pdf', + ]); + expect( + result + .recurrenceSets + .single + .semantic + .components + .single + .documentComponent + .propertiesNamed('ATTACH') + .single + .parameterValue('FMTTYPE'), + 'application/pdf', + ); + }, + ); + + test( + 'CalDAV projection keeps event URL distinct from video conference', + () { + final result = IcalIngestion.parseString( + _calendar(''' +BEGIN:VEVENT +UID:conference +DTSTART:20260830T160000Z +DTEND:20260830T170000Z +SUMMARY:Conference +URL:https://example.com/event +CONFERENCE;FEATURE=VIDEO:https://meet.example.com/room +END:VEVENT +'''), + policy: IcalIngestionPolicy.webCal, + ); + final event = IcalEventProjector() + .project( + result.recurrenceSets.single, + rangeStartUtc: DateTime.utc(2026, 8, 1), + rangeEndUtc: DateTime.utc(2026, 9, 1), + transport: 'caldav', + ) + .single; + expect(event.webLink, 'https://example.com/event'); + expect((jsonDecode(event.rawJson) as Map)['conferenceLinks'], [ + {'url': 'https://meet.example.com/room', 'features': 'VIDEO'}, + ]); + }, + ); test('groups multiple UIDs and retains recurrence exceptions', () { final result = IcalIngestion.parseString( _calendar(''' diff --git a/test/microsoft_calendar/microsoft_calendar_api_client_test.dart b/test/microsoft_calendar/microsoft_calendar_api_client_test.dart index 21028306..03af6e43 100644 --- a/test/microsoft_calendar/microsoft_calendar_api_client_test.dart +++ b/test/microsoft_calendar/microsoft_calendar_api_client_test.dart @@ -1,12 +1,774 @@ 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/core/auth/oauth_models.dart'; +import 'package:busymax/src/core/http/request_dispatch_exception.dart'; import 'package:busymax/src/microsoft_calendar/microsoft_calendar_api_client.dart'; +import 'package:busymax/src/microsoft_calendar/microsoft_calendar_errors.dart'; +import 'package:busymax/src/microsoft_calendar/microsoft_calendar_models.dart'; +import 'package:busymax/src/microsoft_calendar/microsoft_event_attachment.dart'; +import 'package:busymax/src/microsoft_calendar/microsoft_shared_calendar_address.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:http/http.dart' as http; import 'package:http/testing.dart'; void main() { + test( + 'primary permission 429 retains code and Retry-After without retrying', + () async { + var requests = 0; + final client = MicrosoftCalendarApiClient( + httpClient: MockClient((request) async { + requests++; + return http.Response( + jsonEncode({ + 'error': {'code': 'TooManyRequests', 'message': 'Throttled'}, + }), + 429, + headers: {'retry-after': '113'}, + ); + }), + baseUri: Uri.parse('https://graph.microsoft.com/v1.0'), + responseTimeZone: 'UTC', + ); + await expectLater( + client.addPrimaryCalendarPermission( + email: 'friend@example.test', + role: 'read', + ), + throwsA( + isA() + .having((e) => e.statusCode, 'status', 429) + .having((e) => e.code, 'code', 'TooManyRequests') + .having((e) => e.isRateLimited, 'throttle', true) + .having( + (e) => e.retryAfter, + 'retry-after', + const Duration(seconds: 113), + ), + ), + ); + expect(requests, 1); + }, + ); + + test('primary sharing authorization fails before Graph dispatch', () async { + const failure = OAuthException('TokenUnavailable', 'Token unavailable'); + var requests = 0; + var ordinaryHeaders = 0; + var sharedHeaders = 0; + final client = MicrosoftCalendarApiClient( + httpClient: MockClient((request) async { + requests++; + return _json({'value': []}); + }), + baseUri: Uri.parse('https://graph.microsoft.com/v1.0'), + responseTimeZone: 'UTC', + authorizationHeaderProvider: () async { + ordinaryHeaders++; + throw failure; + }, + sharedCalendarAuthorizationHeaderProvider: () async { + sharedHeaders++; + return 'Bearer shared'; + }, + ); + await expectLater( + client.addPrimaryCalendarPermission( + email: 'friend@example.test', + role: 'read', + ), + throwsA( + isA() + .having( + (error) => error.kind, + 'kind', + RequestPreDispatchFailureKind.authentication, + ) + .having((error) => error.cause, 'cause', same(failure)), + ), + ); + expect(requests, 0); + expect(ordinaryHeaders, 1); + expect(sharedHeaders, 0); + }); + + test('primary sharing 401 and failed refresh is known uncommitted', () async { + const failure = OAuthRefreshException( + 'RefreshDenied', + 'Refresh denied', + statusCode: 400, + ); + var recovered = false; + var requests = 0; + var refreshes = 0; + var sharedHeaders = 0; + final client = MicrosoftCalendarApiClient( + httpClient: MockClient((request) async { + requests++; + if (!recovered) return http.Response('{}', 401); + return _json({ + 'id': 'grant', + 'role': 'read', + 'allowedRoles': ['read', 'write'], + 'isRemovable': true, + 'emailAddress': {'address': 'friend@example.test'}, + }); + }), + baseUri: Uri.parse('https://graph.microsoft.com/v1.0'), + responseTimeZone: 'UTC', + authorizationHeaderProvider: () async => 'Bearer calendar', + sharedCalendarAuthorizationHeaderProvider: () async { + sharedHeaders++; + return 'Bearer shared'; + }, + unauthorizedRefreshProvider: () async { + refreshes++; + throw failure; + }, + ); + await expectLater( + client.addPrimaryCalendarPermission( + email: 'friend@example.test', + role: 'read', + ), + throwsA( + isA() + .having( + (error) => error.kind, + 'kind', + RequestPreDispatchFailureKind.authentication, + ) + .having((error) => error.cause, 'cause', same(failure)), + ), + ); + expect(requests, 1); + expect(refreshes, 1); + expect(sharedHeaders, 0); + recovered = true; + await client.addPrimaryCalendarPermission( + email: 'friend@example.test', + role: 'read', + ); + expect(requests, 2); + expect(refreshes, 1); + }); + + test('lost Graph sharing response is not known-unsent', () async { + var requests = 0; + final client = MicrosoftCalendarApiClient( + httpClient: MockClient((request) async { + requests++; + throw http.ClientException('response lost'); + }), + baseUri: Uri.parse('https://graph.microsoft.com/v1.0'), + responseTimeZone: 'UTC', + authorizationHeaderProvider: () async => 'Bearer calendar', + ); + await expectLater( + client.addPrimaryCalendarPermission( + email: 'friend@example.test', + role: 'read', + ), + throwsA(isA()), + ); + expect(requests, 1); + }); + + test( + 'primary calendar permissions paginate and enforce allowed roles', + () async { + final requests = []; + final client = _client((request) { + requests.add(request); + if (request.method == 'GET') { + return _json({ + 'value': [ + { + 'id': 'grant', + 'role': 'read', + 'allowedRoles': ['read', 'write'], + 'isRemovable': true, + 'emailAddress': {'address': 'alex@example.test'}, + }, + ], + if (request.url.queryParameters[r'$skiptoken'] == null) + '@odata.nextLink': + 'https://graph.microsoft.com/v1.0/me/calendar/calendarPermissions?%24skiptoken=next', + }); + } + if (request.method == 'DELETE') return http.Response('', 204); + return _json({ + 'id': 'grant', + 'role': request.method == 'POST' + ? 'read' + : jsonDecode(request.body)['role'], + 'allowedRoles': ['read', 'write'], + 'isRemovable': true, + 'emailAddress': {'address': 'alex@example.test'}, + }); + }); + final grants = await client.listPrimaryCalendarPermissions(); + expect(grants, hasLength(2)); + expect(requests.last.url.queryParameters[r'$skiptoken'], 'next'); + final created = await client.addPrimaryCalendarPermission( + email: 'alex@example.test', + role: 'read', + ); + expect(created.address, 'alex@example.test'); + expect(jsonDecode(requests.last.body), { + 'emailAddress': {'address': 'alex@example.test'}, + 'role': 'read', + }); + await client.changePrimaryCalendarPermission(grants.first, 'write'); + expect(requests.last.method, 'PATCH'); + await expectLater( + client.changePrimaryCalendarPermission( + grants.first, + 'delegateWithPrivateEventAccess', + ), + throwsArgumentError, + ); + await client.revokePrimaryCalendarPermission(grants.first); + expect(requests.last.method, 'DELETE'); + const nonRemovable = MicrosoftCalendarPermission( + id: 'default', + role: 'freeBusyRead', + allowedRoles: ['read'], + isRemovable: false, + ); + expect( + () => client.revokePrimaryCalendarPermission(nonRemovable), + throwsArgumentError, + ); + expect( + (await client.changePrimaryCalendarPermission( + nonRemovable, + 'read', + )).role, + 'read', + ); + }, + ); + test('master category lookup paginates and keeps every named color', () async { + final requests = []; + final client = MicrosoftCalendarApiClient( + httpClient: MockClient((request) async { + requests.add(request); + if (request.url.queryParameters[r'$skiptoken'] == 'next') { + return _json({ + 'value': [ + {'id': 'c2', 'displayName': 'Personal', 'color': 'preset1'}, + ], + }); + } + return _json({ + 'value': [ + {'id': 'c1', 'displayName': 'Work', 'color': 'preset7'}, + ], + '@odata.nextLink': + 'https://graph.microsoft.com/v1.0/me/outlook/masterCategories?%24skiptoken=next', + }); + }), + baseUri: Uri.parse('https://graph.microsoft.com/v1.0'), + responseTimeZone: 'UTC', + ); + final categories = await client.listMasterCategories(); + expect(categories.map((item) => item.displayName), ['Work', 'Personal']); + expect(categories.map((item) => item.color), ['preset7', 'preset1']); + expect(requests, hasLength(2)); + expect(requests.first.url.path, '/v1.0/me/outlook/masterCategories'); + }); + + test('malformed master category response does not look empty', () async { + final client = MicrosoftCalendarApiClient( + httpClient: MockClient((_) async => _json({'value': null})), + baseUri: Uri.parse('https://graph.microsoft.com/v1.0'), + responseTimeZone: 'UTC', + ); + await expectLater(client.listMasterCategories(), throwsFormatException); + }); + + test( + 'master categories use the optional category grant, not ordinary calendar authorization', + () async { + final headers = []; + final client = MicrosoftCalendarApiClient( + httpClient: MockClient((request) async { + headers.add(request.headers['Authorization'] ?? ''); + return _json({'value': []}); + }), + baseUri: Uri.parse('https://graph.microsoft.com/v1.0'), + responseTimeZone: 'UTC', + authorizationHeaderProvider: () async => 'Bearer calendar', + categoryAuthorizationHeaderProvider: () async => 'Bearer categories', + ); + expect(await client.listMasterCategories(), isEmpty); + await client.listCalendars(); + expect(headers, ['Bearer categories', 'Bearer calendar']); + }, + ); + + test( + 'series exception snapshot retains moved and cancelled identities', + () async { + final client = MicrosoftCalendarApiClient( + httpClient: MockClient((request) async { + expect(request.url.path, '/v1.0/me/calendars/cal/events/master'); + expect( + request.url.queryParameters[r'$expand'], + 'exceptionOccurrences', + ); + return _json({ + 'id': 'master', + 'exceptionOccurrences': [ + { + ..._eventJson(id: 'moved', subject: 'Moved'), + 'originalStart': '2026-08-31T16:00:00Z', + 'seriesMasterId': 'master', + }, + ], + 'cancelledOccurrences': ['occurrence-2'], + }); + }), + baseUri: Uri.parse('https://graph.microsoft.com/v1.0'), + responseTimeZone: 'UTC', + ); + final snapshot = await client.getSeriesExceptionSnapshot( + calendarId: 'cal', + recurringEventId: 'master', + ); + expect( + snapshot.exceptions.single.providerOriginalStartKey, + '2026-08-31T16:00:00Z', + ); + expect(snapshot.cancelledIds, {'occurrence-2'}); + }, + ); + test( + 'delegated primary routes retain owner mailbox and use shared consent', + () async { + final requests = []; + final client = MicrosoftCalendarApiClient( + httpClient: MockClient((request) async { + requests.add(request); + final path = request.url.path; + if (path.endsWith('/calendar')) { + return _json({ + 'id': 'owner-calendar-id', + 'name': 'Owner', + 'canEdit': true, + 'isDefaultCalendar': true, + }); + } + if (path.endsWith('/attachments')) { + return _json({'value': []}); + } + if (path.endsWith('/calendarView') || path.endsWith('/instances')) { + return _json({ + 'value': [_eventJson(id: 'owner-event', subject: 'Shared')], + }); + } + return _json(_eventJson(id: 'owner-event', subject: 'Shared')); + }), + baseUri: Uri.parse('https://graph.microsoft.com/v1.0'), + responseTimeZone: 'UTC', + authorizationHeaderProvider: () async => 'Bearer ordinary', + sharedCalendarAuthorizationHeaderProvider: () async => 'Bearer shared', + ); + final source = await client.getSharedPrimaryCalendar('owner@example.com'); + final key = source.providerCalendarId; + expect(source.primaryCalendar, isFalse); + expect(source.readOnly, isFalse); + expect( + MicrosoftSharedPrimaryCalendarAddress.parse(key)?.graphCalendarId, + 'owner-calendar-id', + ); + final start = DateTime.utc(2026, 6, 1); + final end = DateTime.utc(2026, 7, 1); + await client.listEvents( + calendarId: key, + rangeStart: start, + rangeEnd: end, + ); + await client.getEvent(calendarId: key, eventId: 'owner-event'); + await client.updateEvent( + calendarId: key, + eventId: 'owner-event', + mutation: const CalendarEventMutation(title: 'Updated'), + ); + await client.listEventInstances( + calendarId: key, + recurringEventId: 'master', + rangeStart: start, + rangeEnd: end, + ); + await client.listEventAttachments( + calendarId: key, + eventId: 'owner-event', + ); + expect(requests, hasLength(6)); + for (final request in requests) { + expect( + request.url.path, + startsWith('/v1.0/users/owner%40example.com/'), + ); + expect(request.headers['authorization'], 'Bearer shared'); + } + expect( + requests[1].url.path, + contains('/calendars/owner-calendar-id/calendarView'), + ); + expect(requests[4].url.path, contains('/events/master/instances')); + }, + ); + + test('malformed event attachment collection does not become empty', () async { + final client = _client((_) => _json({'error': 'missing collection'})); + await expectLater( + client.listEventAttachments(calendarId: 'cal', eventId: 'event'), + throwsFormatException, + ); + }); + + test( + 'small event attachment add and removal use event-scoped routes', + () async { + final requests = []; + final client = _client((request) { + requests.add(request); + return request.method == 'DELETE' + ? http.Response('', 204) + : _json({ + 'id': 'attachment-1', + 'name': 'Plan.txt', + '@odata.type': '#microsoft.graph.fileAttachment', + }); + }); + final created = await client.createSmallEventAttachment( + calendarId: 'cal', + eventId: 'event', + name: 'Plan.txt', + contentType: 'text/plain', + bytes: [65], + ); + await client.deleteEventAttachment( + calendarId: 'cal', + eventId: 'event', + attachmentId: created.id, + ); + expect(requests.map((request) => request.method), ['POST', 'DELETE']); + expect( + requests.first.url.path, + '/v1.0/me/calendars/cal/events/event/attachments', + ); + expect((jsonDecode(requests.first.body) as Map)['contentBytes'], 'QQ=='); + expect( + requests.last.url.path, + '/v1.0/me/calendars/cal/events/event/attachments/attachment-1', + ); + }, + ); + + test( + 'large event attachment upload never sends Graph bearer to upload URL', + () async { + final requests = []; + final client = _client((request) { + requests.add(request); + if (request.method == 'POST') { + return _json({ + 'uploadUrl': 'https://outlook.office.com/upload/session', + 'nextExpectedRanges': ['0-'], + 'expirationDateTime': '2099-01-01T00:00:00Z', + }); + } + return request.headers['Content-Range']!.startsWith('bytes 2097152-') + ? http.Response( + '', + 201, + headers: { + 'Location': + "https://outlook.office.com/api/v2.0/Events('event')/Attachments('new-id')", + }, + ) + : _json({ + 'nextExpectedRanges': ['2097152-'], + }); + }); + final id = await client.uploadEventFileAttachment( + calendarId: 'cal', + eventId: 'event', + name: 'large.bin', + contentType: 'application/octet-stream', + bytes: List.filled(3 * 1024 * 1024, 65), + ); + expect(id, 'new-id'); + expect( + requests.first.url.path, + '/v1.0/me/events/event/attachments/createUploadSession', + ); + expect(requests.first.headers['authorization'], 'Bearer token'); + expect(requests.skip(1).map((request) => request.method), ['PUT', 'PUT']); + expect( + requests + .skip(1) + .every((request) => !request.headers.containsKey('authorization')), + isTrue, + ); + }, + ); + + test( + 'event attachment metadata paginates without treating flags as files', + () async { + final paths = []; + final client = _client((request) { + paths.add(request.url.path); + expect(request.headers['authorization'], 'Bearer token'); + return request.url.queryParameters.containsKey(r'$skiptoken') + ? _json({ + 'value': [ + { + 'id': 'ref', + 'name': 'Plan', + '@odata.type': '#microsoft.graph.referenceAttachment', + 'sourceUrl': 'https://example.test/plan', + }, + ], + }) + : _json({ + 'value': [ + { + 'id': 'file', + 'name': 'Agenda.pdf', + '@odata.type': '#microsoft.graph.fileAttachment', + 'size': 5, + }, + ], + '@odata.nextLink': + 'https://graph.microsoft.com/v1.0/me/calendars/cal/events/event/attachments?\$skiptoken=next', + }); + }); + final attachments = await client.listEventAttachments( + calendarId: 'cal', + eventId: 'event', + ); + expect(paths, [ + '/v1.0/me/calendars/cal/events/event/attachments', + '/v1.0/me/calendars/cal/events/event/attachments', + ]); + expect(attachments.map((attachment) => attachment.kind), [ + MicrosoftEventAttachmentKind.file, + MicrosoftEventAttachmentKind.reference, + ]); + expect(attachments.first.size, 5); + expect(attachments.last.canDownload, isFalse); + }, + ); + + test( + 'event attachment download is explicit and rejects reference kinds', + () async { + final client = _client((request) { + expect( + request.url.path, + r'/v1.0/me/calendars/cal/events/event/attachments/file/$value', + ); + return http.Response.bytes([1, 2, 3], 200); + }); + final data = await client.downloadEventAttachment( + calendarId: 'cal', + eventId: 'event', + attachment: const MicrosoftEventAttachment( + id: 'file', + name: 'a.bin', + kind: MicrosoftEventAttachmentKind.file, + ), + ); + expect(data, [1, 2, 3]); + expect( + () => client.downloadEventAttachment( + calendarId: 'cal', + eventId: 'event', + attachment: const MicrosoftEventAttachment( + id: 'ref', + name: 'link', + kind: MicrosoftEventAttachmentKind.reference, + ), + ), + throwsUnsupportedError, + ); + }, + ); + + test( + 'untrusted attachment pagination never forwards authorization', + () async { + var calls = 0; + final client = _client((request) { + calls++; + return _json({ + 'value': [], + '@odata.nextLink': 'https://attacker.example/steal', + }); + }); + await expectLater( + client.listEventAttachments(calendarId: 'cal', eventId: 'event'), + throwsFormatException, + ); + expect(calls, 1); + }, + ); + + test( + 'getSchedule preserves mixed recipient outcomes and UTC intervals', + () async { + late http.Request captured; + final client = _client((request) { + captured = request; + return _json({ + 'value': [ + { + 'scheduleId': 'busy@example.test', + 'scheduleItems': [ + { + 'status': 'busy', + 'start': { + 'dateTime': '2026-03-08T09:00:00', + 'timeZone': 'UTC', + }, + 'end': {'dateTime': '2026-03-08T10:00:00', 'timeZone': 'UTC'}, + }, + ], + }, + {'scheduleId': 'free@example.test', 'scheduleItems': []}, + { + 'scheduleId': 'denied@example.test', + 'error': {'responseCode': '5003', 'message': 'No access'}, + }, + ], + }); + }); + final results = await client.freeBusyDetails( + calendarIds: const [ + 'busy@example.test', + 'free@example.test', + 'denied@example.test', + 'missing@example.test', + ], + rangeStart: DateTime.utc(2026, 3, 8), + rangeEnd: DateTime.utc(2026, 3, 9), + ); + expect(captured.method, 'POST'); + expect(captured.url.path, '/v1.0/me/calendar/getSchedule'); + expect((jsonDecode(captured.body) as Map)['startTime'], { + 'dateTime': '2026-03-08T00:00:00.000', + 'timeZone': 'UTC', + }); + expect(results.map((result) => result.status), [ + FreeBusyEvaluationStatus.success, + FreeBusyEvaluationStatus.success, + FreeBusyEvaluationStatus.failed, + FreeBusyEvaluationStatus.missing, + ]); + expect(results[0].busySlots.single.start, DateTime.utc(2026, 3, 8, 9)); + expect(results[1].busySlots, isEmpty); + expect(results[2].errors, contains('5003')); + }, + ); + + test('getSchedule rejects invalid intervals without a request', () async { + final client = _client((_) => throw StateError('No request expected')); + final results = await client.freeBusyDetails( + calendarIds: const ['guest@example.test'], + rangeStart: DateTime.utc(2026, 1, 2), + rangeEnd: DateTime.utc(2026, 1, 1), + ); + expect(results.single.status, FreeBusyEvaluationStatus.failed); + }); + + test( + 'personal Microsoft token never calls unsupported getSchedule', + () async { + final client = MicrosoftCalendarApiClient( + httpClient: MockClient( + (_) async => throw StateError('No request expected'), + ), + baseUri: Uri.parse('https://graph.microsoft.com/v1.0'), + responseTimeZone: 'UTC', + authorizationHeaderProvider: () async => 'Bearer opaque', + accountTenantId: '9188040d-6c67-4c5b-b112-36a304b66dad', + ); + final results = await client.freeBusyDetails( + calendarIds: const ['guest@example.test'], + rangeStart: DateTime.utc(2026, 1, 1), + rangeEnd: DateTime.utc(2026, 1, 2), + ); + expect(results.single.status, FreeBusyEvaluationStatus.failed); + expect(results.single.errors.single, contains('personal')); + }, + ); + + test( + 'unknown account metadata is not assumed to support getSchedule', + () async { + final client = MicrosoftCalendarApiClient( + httpClient: MockClient( + (_) async => throw StateError('No request expected'), + ), + baseUri: Uri.parse('https://graph.microsoft.com/v1.0'), + responseTimeZone: 'UTC', + authorizationHeaderProvider: () async => 'Bearer opaque', + ); + final results = await client.freeBusyDetails( + calendarIds: const ['guest@example.test'], + rangeStart: DateTime.utc(2026, 1, 1), + rangeEnd: DateTime.utc(2026, 1, 2), + ); + expect(results.single.status, FreeBusyEvaluationStatus.failed); + expect(results.single.errors.single, contains('type is unknown')); + }, + ); + + test( + 'getSchedule batches at 20 recipients and retains failed batch', + () async { + var calls = 0; + final client = _client((request) { + calls++; + if (calls == 2) { + return http.Response('{"error":{"code":"ErrorAccessDenied"}}', 403); + } + final recipients = + (jsonDecode(request.body) as Map)['schedules'] as List; + expect(recipients, hasLength(20)); + return _json({ + 'value': [ + for (final recipient in recipients) + {'scheduleId': recipient, 'scheduleItems': []}, + ], + }); + }); + final results = await client.freeBusyDetails( + calendarIds: [ + for (var index = 0; index < 25; index++) 'guest$index@example.test', + ], + rangeStart: DateTime.utc(2026, 1, 1), + rangeEnd: DateTime.utc(2026, 1, 2), + ); + expect(calls, 2); + expect(results.take(20).every((value) => value.succeeded), isTrue); + expect( + results + .skip(20) + .every((value) => value.status == FreeBusyEvaluationStatus.failed), + isTrue, + ); + }, + ); + test('calendar update sends the documented Microsoft color enum', () async { late http.Request captured; final client = _client((request) { @@ -291,6 +1053,7 @@ MicrosoftCalendarApiClient _client( baseUri: Uri.parse('https://graph.microsoft.com/v1.0'), responseTimeZone: 'UTC', authorizationHeaderProvider: () async => 'Bearer token', + accountTenantId: '11111111-1111-1111-1111-111111111111', ); } diff --git a/test/microsoft_calendar/microsoft_calendar_mapper_test.dart b/test/microsoft_calendar/microsoft_calendar_mapper_test.dart index 13807c71..2b9dcb83 100644 --- a/test/microsoft_calendar/microsoft_calendar_mapper_test.dart +++ b/test/microsoft_calendar/microsoft_calendar_mapper_test.dart @@ -42,6 +42,18 @@ void main() { expect(source.isRemovable, isFalse); }); + test('event category names are not treated as provider color IDs', () { + final event = microsoftCalendarEventFromJson('calendar', { + 'id': 'event', + 'subject': 'Event', + 'categories': ['Planning', 'Urgent'], + 'start': {'dateTime': '2026-09-30T09:00:00', 'timeZone': 'UTC'}, + 'end': {'dateTime': '2026-09-30T10:00:00', 'timeZone': 'UTC'}, + }); + expect(event.categoriesJson, ['Planning', 'Urgent']); + expect(event.colorId, isNull); + }); + test( 'Microsoft calendar source maps named colors when hex color is empty', () { @@ -93,7 +105,36 @@ void main() { }); expect(event.categoriesJson, ['Work', 'Blue category']); - expect(event.colorId, 'Work'); + expect(event.colorId, isNull); + }); + + test('attachment flag is not treated as a loaded empty list', () { + Map event(bool? hasAttachments, {Object? attachments}) => { + 'id': 'event-1', + 'subject': 'Planning', + if (hasAttachments != null) 'hasAttachments': hasAttachments, + if (attachments != null) 'attachments': attachments, + }; + expect( + microsoftCalendarEventFromJson('cal', event(true)).attachmentsJson, + isNull, + ); + expect( + microsoftCalendarEventFromJson('cal', event(false)).attachmentsJson, + isEmpty, + ); + expect( + microsoftCalendarEventFromJson( + 'cal', + event( + true, + attachments: [ + {'id': 'file-1', 'name': 'Agenda.pdf'}, + ], + ), + ).attachmentsJson, + hasLength(1), + ); }); test('formatted Microsoft HTML is converted to plain text with ranges', () { diff --git a/test/microsoft_calendar/microsoft_shared_calendar_projection_test.dart b/test/microsoft_calendar/microsoft_shared_calendar_projection_test.dart new file mode 100644 index 00000000..c0671766 --- /dev/null +++ b/test/microsoft_calendar/microsoft_shared_calendar_projection_test.dart @@ -0,0 +1,88 @@ +import 'package:busymax/src/calendar_providers/calendar_sync_dto.dart'; +import 'package:busymax/src/db/app_database.dart'; +import 'package:busymax/src/features/calendar/data/calendar_repository.dart'; +import 'package:busymax/src/microsoft_calendar/microsoft_shared_calendar_address.dart'; +import 'package:busymax/src/providers/busy_provider.dart'; +import 'package:busymax/src/schedule/schedule_item.dart'; +import 'package:busymax/src/schedule/schedule_range.dart'; +import 'package:busymax/src/schedule/schedule_repository.dart'; +import 'package:drift/drift.dart' show Value; +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test( + 'restricted delegated private item hides cached details and cannot queue deletion', + () async { + final database = AppDatabase(NativeDatabase.memory()); + addTearDown(database.close); + await database + .into(database.accounts) + .insert( + AccountsCompanion.insert( + id: 'microsoft:a', + provider: BusyProvider.microsoft.storageValue, + authority: 'https://login.microsoftonline.com/common', + providerAccountId: 'a', + credentialKind: 'oauth', + authState: const Value('signed_in'), + grantedScopes: const Value(''), + createdAtUtc: '2026-07-01T00:00:00.000Z', + updatedAtUtc: '2026-07-01T00:00:00.000Z', + ), + ); + final calendarId = const MicrosoftSharedPrimaryCalendarAddress( + owner: 'owner@example.com', + graphCalendarId: 'owner-calendar', + ).sourceCalendarId; + final repository = CalendarRepository(database: database); + await repository.upsertSource( + accountId: 'microsoft:a', + source: CalendarSourceDto( + provider: BusyProvider.microsoft, + providerCalendarId: calendarId, + summary: 'Owner calendar', + readOnly: false, + rawJson: const {'canViewPrivateItems': false}, + ), + ); + await repository.upsertEvent( + accountId: 'microsoft:a', + event: CalendarEventDto( + provider: BusyProvider.microsoft, + providerCalendarId: calendarId, + providerEventId: 'private-event', + title: 'Sensitive title', + description: 'Sensitive description', + location: 'Sensitive location', + startDateTime: '2026-07-15T09:00:00', + endDateTime: '2026-07-15T10:00:00', + startTimeZone: 'UTC', + endTimeZone: 'UTC', + visibility: 'private', + webLink: 'https://outlook.example/private', + attachmentsJson: const [ + {'fileUrl': 'https://files.example/private', 'title': 'Secret'}, + ], + rawJson: const {'hasAttachments': true}, + ), + ); + final items = await ScheduleRepository( + database, + ).listItems(range: ScheduleRange.day(DateTime(2026, 7, 15))); + final item = items.single as CalendarScheduleItem; + expect(item.title, isNot(contains('Sensitive'))); + expect(item.description, isNull); + expect(item.location, isNull); + expect(item.eventLinkUrl, isNull); + expect(item.attachmentLinks, isEmpty); + expect(item.capabilities.canEdit, isFalse); + expect(item.capabilities.canDelete, isFalse); + await expectLater( + repository.deleteLocalEvent(item.id), + throwsA(isA()), + ); + expect(await database.select(database.pendingOps).get(), 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 0cff6c81..43a6d80c 100644 --- a/test/microsoft_todo/api/microsoft_todo_api_client_test.dart +++ b/test/microsoft_todo/api/microsoft_todo_api_client_test.dart @@ -1,12 +1,595 @@ +import 'dart:async'; import 'dart:convert'; 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/microsoft_todo/api/microsoft_todo_api_error.dart'; import 'package:busymax/src/core/http/request_dispatch_exception.dart'; +import 'package:busymax/src/features/schedule/presentation/attachment_upload_coordinator.dart'; void main() { + test( + 'fresh direct task 429 retains retry delay and allows one explicit retry', + () async { + var now = DateTime.utc(2026, 10, 1); + var posts = 0; + final heldRefresh = Completer(); + final refreshStarted = Completer(); + final client = MicrosoftTodoRestApiClient( + httpClient: MockClient((request) async { + if (request.method == 'GET') { + if (posts == 2) { + refreshStarted.complete(); + return heldRefresh.future; + } + return http.Response(jsonEncode({'value': []}), 200); + } + posts++; + if (posts == 1) { + return http.Response( + jsonEncode({ + 'error': {'code': 'TooManyRequests', 'message': 'Slow down'}, + }), + 429, + headers: {'retry-after': '30'}, + ); + } + return http.Response( + jsonEncode({ + 'id': 'confirmed-task-attachment', + 'name': 'notes.txt', + 'size': 1, + '@odata.type': '#microsoft.graph.taskFileAttachment', + }), + 201, + ); + }), + baseUri: Uri.parse('https://graph.microsoft.com/v1.0'), + ); + final coordinator = AttachmentUploadCoordinator(now: () => now); + addTearDown(coordinator.dispose); + final key = AttachmentUploadCoordinator.taskKey( + 'account', + 'list', + 'task', + ); + Future upload() => coordinator.uploadTask( + client: client, + accountId: 'account', + taskListId: 'list', + taskId: 'task', + name: 'notes.txt', + contentType: 'text/plain', + bytes: const [1], + ); + await expectLater( + upload(), + throwsA( + isA().having( + (e) => e.retryAfter, + 'retryAfter', + const Duration(seconds: 30), + ), + ), + ); + expect(posts, 1); + expect(coordinator.needsReconciliation(key), isFalse); + expect(coordinator.retryAfter(key), const Duration(seconds: 30)); + expect(coordinator.canSubmit(key), isFalse); + expect( + await client.listTaskAttachmentsPage( + taskListId: 'list', + taskId: 'task', + ), + isNotNull, + ); + await expectLater( + upload(), + throwsA(isA()), + ); + expect(posts, 1); + now = now.add(const Duration(seconds: 31)); + await upload(); + await refreshStarted.future; + expect(posts, 2); + expect(coordinator.status(key), AttachmentUploadStatus.committed); + expect(coordinator.canSubmit(key), isTrue); + heldRefresh.complete(http.Response(jsonEncode({'value': []}), 200)); + }, + ); + test( + 'lost nonfinal task chunk resumes its Graph session and keeps bearer scoped', + () async { + var sessions = 0; + var statusReads = 0; + var finalChunkAcknowledged = false; + final heldRefresh = Completer(); + final refreshStarted = Completer(); + final ranges = []; + final bytes = List.filled(4 * 1024 * 1024, 65); + final client = MicrosoftTodoRestApiClient( + httpClient: MockClient((request) async { + if (request.method == 'GET' && + request.url.path.endsWith('/attachments')) { + if (finalChunkAcknowledged) { + refreshStarted.complete(); + return heldRefresh.future; + } + return http.Response(jsonEncode({'value': []}), 200); + } + if (request.method == 'POST') { + sessions++; + return http.Response( + jsonEncode({ + 'uploadUrl': + 'https://graph.microsoft.com/v1.0/me/todo/lists/list/tasks/task/attachmentSessions/session', + 'expirationDateTime': '2099-01-01T00:00:00Z', + 'nextExpectedRanges': ['0-'], + }), + 201, + ); + } + if (request.method == 'GET' && + request.url.path.endsWith('/attachmentSessions/session')) { + statusReads++; + expect(request.headers['authorization'], 'Bearer token'); + return http.Response( + jsonEncode({ + 'nextExpectedRanges': ['2097152-'], + }), + 200, + ); + } + if (request.method == 'PUT') { + expect(request.url.path.endsWith('/content'), isTrue); + expect(request.headers['authorization'], 'Bearer token'); + final range = + request.headers['content-range'] ?? + request.headers['Content-Range']!; + ranges.add(range); + if (range.startsWith('bytes 0-')) { + throw http.ClientException('first response lost'); + } + finalChunkAcknowledged = true; + return http.Response( + '', + 201, + headers: { + 'Location': + 'https://graph.microsoft.com/v1.0/me/todo/lists/list/tasks/task/attachments/task-upload-id', + }, + ); + } + return http.Response('{}', 404); + }), + baseUri: Uri.parse('https://graph.microsoft.com/v1.0'), + authorizationHeaderProvider: () async => 'Bearer token', + ); + final coordinator = AttachmentUploadCoordinator(); + final key = AttachmentUploadCoordinator.taskKey( + 'account', + 'list', + 'task', + ); + await expectLater( + coordinator.uploadTask( + client: client, + accountId: 'account', + taskListId: 'list', + taskId: 'task', + name: 'large.bin', + contentType: 'application/octet-stream', + bytes: bytes, + ), + throwsA(isA()), + ); + expect(coordinator.status(key), AttachmentUploadStatus.unresolved); + var completed = false; + final resumed = coordinator + .reconcileTask( + client: client, + accountId: 'account', + taskListId: 'list', + taskId: 'task', + ) + .then((status) { + completed = true; + return status; + }); + await refreshStarted.future; + for (var i = 0; i < 4; i++) { + await Future.value(); + } + try { + expect(completed, isTrue); + expect(await resumed, AttachmentUploadStatus.committed); + } finally { + heldRefresh.complete(http.Response(jsonEncode({'value': []}), 200)); + await resumed; + } + for (var i = 0; i < 4; i++) { + await Future.value(); + } + expect(coordinator.confirmedId(key), equals(null)); + expect(coordinator.canSubmit(key), isTrue); + expect(sessions, 1); + expect(statusReads, 1); + expect(ranges, [ + 'bytes 0-2097151/${bytes.length}', + 'bytes 2097152-4194303/${bytes.length}', + ]); + }, + ); + test( + 'unfinished task session cancellation is Graph authenticated and releases retry', + () async { + var cancellations = 0; + final client = MicrosoftTodoRestApiClient( + httpClient: MockClient((request) async { + if (request.method == 'GET') { + return http.Response(jsonEncode({'value': []}), 200); + } + if (request.method == 'POST') { + return http.Response( + jsonEncode({ + 'uploadUrl': + 'https://graph.microsoft.com/v1.0/me/todo/lists/list/tasks/task/attachmentSessions/session', + 'expirationDateTime': '2099-01-01T00:00:00Z', + 'nextExpectedRanges': ['0-'], + }), + 201, + ); + } + if (request.method == 'PUT') { + throw http.ClientException('chunk response lost'); + } + if (request.method == 'DELETE') { + cancellations++; + expect( + request.url.path.endsWith('/attachmentSessions/session'), + isTrue, + ); + expect(request.headers['authorization'], 'Bearer token'); + return http.Response('', 204); + } + return http.Response('{}', 404); + }), + baseUri: Uri.parse('https://graph.microsoft.com/v1.0'), + authorizationHeaderProvider: () async => 'Bearer token', + ); + final coordinator = AttachmentUploadCoordinator(); + final key = AttachmentUploadCoordinator.taskKey( + 'account', + 'list', + 'task', + ); + await expectLater( + coordinator.uploadTask( + client: client, + accountId: 'account', + taskListId: 'list', + taskId: 'task', + name: 'large.bin', + contentType: 'application/octet-stream', + bytes: List.filled(4 * 1024 * 1024, 65), + ), + throwsA(isA()), + ); + expect(coordinator.hasResumableSession(key), isTrue); + await coordinator.cancelTask( + client: client, + accountId: 'account', + taskListId: 'list', + taskId: 'task', + ); + expect(cancellations, 1); + expect(coordinator.canSubmit(key), isTrue); + }, + ); + test( + 'lost task session response cannot start another upload before reconciliation', + () async { + var visible = false; + var sessions = 0; + var chunks = 0; + final bytes = List.filled(3 * 1024 * 1024, 65); + final client = MicrosoftTodoRestApiClient( + httpClient: MockClient((request) async { + if (request.method == 'GET' && + request.url.path.endsWith('/attachments')) { + return http.Response( + jsonEncode({ + 'value': visible + ? [ + { + 'id': 'file-1', + 'name': 'large.bin', + 'size': bytes.length, + '@odata.type': '#microsoft.graph.taskFileAttachment', + }, + ] + : [], + }), + 200, + ); + } + if (request.method == 'POST' && + request.url.path.endsWith('/createUploadSession')) { + sessions++; + return http.Response( + jsonEncode({ + 'uploadUrl': + 'https://graph.microsoft.com/v1.0/me/todo/lists/list/tasks/task/attachments/session', + 'nextExpectedRanges': ['0-'], + 'expirationDateTime': '2099-01-01T00:00:00Z', + }), + 200, + ); + } + if (request.method == 'PUT') { + chunks++; + if (chunks == 2) throw http.ClientException('final response lost'); + return http.Response( + jsonEncode({ + 'nextExpectedRanges': ['2097152-'], + }), + 200, + ); + } + return http.Response('{}', 404); + }), + baseUri: Uri.parse('https://graph.microsoft.com/v1.0'), + ); + final coordinator = AttachmentUploadCoordinator(); + Future upload() => coordinator.uploadTask( + client: client, + accountId: 'account', + taskListId: 'list', + taskId: 'task', + name: 'large.bin', + contentType: 'application/octet-stream', + bytes: bytes, + ); + await expectLater( + upload(), + throwsA(isA()), + ); + await expectLater( + upload(), + throwsA(isA()), + ); + expect(sessions, 1); + expect(chunks, 2); + visible = true; + await expectLater( + coordinator.reconcileTask( + client: client, + accountId: 'account', + taskListId: 'list', + taskId: 'task', + ), + throwsA(isA()), + ); + }, + ); + test('task upload session uses task endpoint and Graph-only bearer', () async { + final requests = []; + final client = _client((request) { + requests.add(request); + if (request.method == 'POST') { + return _json({ + 'uploadUrl': + 'https://graph.microsoft.com/v1.0/users/owner/todo/lists/list/tasks/task/attachmentSessions/session', + 'nextExpectedRanges': ['0-'], + 'expirationDateTime': '2099-01-01T00:00:00Z', + }); + } + return request.headers['content-range']?.startsWith('bytes 2097152-') == + true + ? http.Response( + '', + 201, + headers: { + 'Location': + 'https://graph.microsoft.com/v1.0/me/todo/lists/list/tasks/task/attachments/new-id', + }, + ) + : _json({ + 'nextExpectedRanges': ['2097152-'], + }); + }); + final id = await client.uploadTaskFileAttachment( + taskListId: 'list', + taskId: 'task', + name: 'large.bin', + contentType: 'application/octet-stream', + bytes: List.filled(3 * 1024 * 1024, 65), + ); + expect(id, 'new-id'); + expect( + requests.first.url.path, + '/v1.0/me/todo/lists/list/tasks/task/attachments/createUploadSession', + ); + expect((jsonDecode(requests.first.body) as Map)['attachmentInfo'], { + 'attachmentType': 'file', + 'name': 'large.bin', + 'size': 3 * 1024 * 1024, + }); + expect( + requests.skip(1).map((request) => request.url.path), + everyElement( + '/v1.0/users/owner/todo/lists/list/tasks/task/attachmentSessions/session/content', + ), + ); + expect( + requests + .skip(1) + .every( + (request) => request.headers['authorization'] == 'Bearer token', + ), + isTrue, + ); + }); + + test( + 'task attachments use task-scoped metadata, content and mutation endpoints', + () async { + final requests = []; + final client = _client((request) { + requests.add(request); + if (request.method == 'DELETE') { + return http.Response('', 204); + } + if (request.url.path.endsWith(r'/$value')) { + return http.Response.bytes([1, 2], 200); + } + if (request.method == 'POST') { + return _json({ + 'id': 'file-1', + 'name': 'notes.txt', + '@odata.type': '#microsoft.graph.taskFileAttachment', + }); + } + return _json({ + 'value': [ + { + 'id': 'file-1', + 'name': 'notes.txt', + '@odata.type': '#microsoft.graph.taskFileAttachment', + 'size': 2, + }, + ], + }); + }); + final page = await client.listTaskAttachmentsPage( + taskListId: 'list', + taskId: 'task', + ); + expect(page.attachments.single.isFile, isTrue); + expect( + await client.downloadTaskAttachment( + taskListId: 'list', + taskId: 'task', + attachmentId: 'file-1', + ), + [1, 2], + ); + await client.createSmallTaskAttachment( + taskListId: 'list', + taskId: 'task', + name: 'notes.txt', + contentType: 'text/plain', + bytes: [1, 2], + ); + await client.deleteTaskAttachment( + taskListId: 'list', + taskId: 'task', + attachmentId: 'file-1', + ); + expect(requests.map((request) => request.method), [ + 'GET', + 'GET', + 'POST', + 'DELETE', + ]); + expect( + requests[0].url.path, + '/v1.0/me/todo/lists/list/tasks/task/attachments', + ); + expect( + requests[1].url.path, + r'/v1.0/me/todo/lists/list/tasks/task/attachments/file-1/$value', + ); + expect(jsonDecode(requests[2].body), { + '@odata.type': '#microsoft.graph.taskFileAttachment', + 'name': 'notes.txt', + 'contentType': 'text/plain', + 'size': 2, + 'contentBytes': 'AQI=', + }); + expect( + requests[3].url.path, + '/v1.0/me/todo/lists/list/tasks/task/attachments/file-1', + ); + }, + ); + + test( + 'task attachment continuation rejects another host and invalid uploads', + () async { + var requests = 0; + final client = _client((request) { + requests++; + return _json({'value': []}); + }); + await expectLater( + client.listTaskAttachmentsPage( + taskListId: 'list', + taskId: 'task', + nextLink: 'https://attacker.test/steal', + ), + throwsFormatException, + ); + await expectLater( + client.createSmallTaskAttachment( + taskListId: 'list', + taskId: 'task', + name: '../escape', + contentType: 'text/plain', + bytes: [1], + ), + throwsArgumentError, + ); + expect(requests, 0); + }, + ); + + test('linked resources use task-scoped endpoint and safe pagination', () async { + final requests = []; + final client = _client((request) { + requests.add(request); + return _json({ + 'value': [ + { + 'id': 'resource-1', + 'applicationName': 'Planner', + 'displayName': 'Project', + 'webUrl': 'https://example.test/project', + }, + ], + '@odata.nextLink': + 'https://graph.microsoft.com/v1.0/me/todo/lists/list-1/tasks/task-1/linkedResources?\$skiptoken=next', + }); + }); + final first = await client.listLinkedResourcesPage( + taskListId: 'list-1', + taskId: 'task-1', + ); + expect(first.resources.single.displayName, 'Project'); + expect(first.resources.single.applicationName, 'Planner'); + expect( + requests.single.url.path, + '/v1.0/me/todo/lists/list-1/tasks/task-1/linkedResources', + ); + await client.listLinkedResourcesPage( + taskListId: 'list-1', + taskId: 'task-1', + nextLink: first.nextLink, + ); + expect(requests, hasLength(2)); + await expectLater( + client.listLinkedResourcesPage( + taskListId: 'list-1', + taskId: 'task-1', + nextLink: 'https://attacker.test/steal', + ), + throwsFormatException, + ); + expect(requests, hasLength(2)); + }); + test('list lists sends GET /me/todo/lists', () async { late http.Request captured; final client = _client((request) { diff --git a/test/microsoft_todo/api/microsoft_todo_api_models_test.dart b/test/microsoft_todo/api/microsoft_todo_api_models_test.dart index afe28992..96723612 100644 --- a/test/microsoft_todo/api/microsoft_todo_api_models_test.dart +++ b/test/microsoft_todo/api/microsoft_todo_api_models_test.dart @@ -2,6 +2,72 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:busymax/src/microsoft_todo/api/microsoft_todo_api_models.dart'; void main() { + test('task attachment metadata does not imply content is downloaded', () { + final page = MicrosoftTodoAttachmentsPageDto.fromJson(const { + 'value': [ + { + 'id': 'file', + 'name': 'Notes.txt', + 'size': 8, + '@odata.type': '#microsoft.graph.taskFileAttachment', + }, + ], + '@odata.nextLink': 'next', + }); + expect(page.attachments.single.name, 'Notes.txt'); + expect(page.attachments.single.size, 8); + expect(page.attachments.single.isFile, isTrue); + expect(page.nextLink, 'next'); + }); + + test('linked resource page retains supplied names and URL', () { + final page = MicrosoftTodoLinkedResourcesPageDto.fromJson({ + 'value': [ + { + 'id': 'a', + 'applicationName': 'Planner', + 'displayName': 'Launch plan', + 'webUrl': 'https://example.test/plan', + }, + {'id': 'b', 'applicationName': 'Outlook'}, + ], + '@odata.nextLink': 'https://graph.microsoft.com/v1.0/next', + }); + expect(page.resources, hasLength(2)); + expect(page.resources.first.webUrl, 'https://example.test/plan'); + expect(page.resources.last.webUrl, isNull); + expect(page.nextLink, isNotNull); + }); + + test('malformed detail collections are not successful empty pages', () { + expect( + () => MicrosoftTodoLinkedResourcesPageDto.fromJson(const {}), + throwsFormatException, + ); + expect( + () => MicrosoftTodoLinkedResourcesPageDto.fromJson(const {'value': {}}), + throwsFormatException, + ); + expect( + MicrosoftTodoLinkedResourcesPageDto.fromJson(const { + 'value': [], + }).resources, + isEmpty, + ); + expect( + () => MicrosoftTodoAttachmentsPageDto.fromJson(const {}), + throwsFormatException, + ); + expect( + () => MicrosoftTodoAttachmentsPageDto.fromJson(const {'value': null}), + throwsFormatException, + ); + expect( + MicrosoftTodoAttachmentsPageDto.fromJson(const {'value': []}).attachments, + isEmpty, + ); + }); + test('parses todoTask with Microsoft-specific fields and removed marker', () { final task = MicrosoftTodoTaskDto.fromJson({ '@odata.etag': 'etag-1', diff --git a/test/microsoft_todo/api/microsoft_todo_linked_resources_provider_test.dart b/test/microsoft_todo/api/microsoft_todo_linked_resources_provider_test.dart new file mode 100644 index 00000000..f0769da4 --- /dev/null +++ b/test/microsoft_todo/api/microsoft_todo_linked_resources_provider_test.dart @@ -0,0 +1,125 @@ +import 'dart:convert'; + +import 'package:busymax/src/app/app_bootstrap.dart'; +import 'package:busymax/src/microsoft_todo/api/microsoft_todo_api_client.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'; + +void main() { + test('a malformed later page fails the whole linked-resource lookup', () async { + final client = MicrosoftTodoRestApiClient( + httpClient: MockClient( + (request) async => http.Response( + jsonEncode( + request.url.queryParameters.containsKey(r'$skiptoken') + ? {'error': 'incomplete'} + : { + 'value': [ + {'id': 'first', 'webUrl': 'https://example.test/task'}, + ], + '@odata.nextLink': + 'https://graph.microsoft.com/v1.0/me/todo/lists/list/tasks/task/linkedResources?\$skiptoken=next', + }, + ), + 200, + ), + ), + baseUri: Uri.parse('https://graph.microsoft.com/v1.0'), + authorizationHeaderProvider: () async => 'Bearer token', + ); + final container = ProviderContainer( + overrides: [ + microsoftTodoApiClientForAccountProvider( + 'microsoft:a', + ).overrideWithValue(client), + ], + ); + addTearDown(container.dispose); + await expectLater( + container.read( + microsoftTaskLinkedResourcesProvider(( + accountId: 'microsoft:a', + taskListId: 'list', + taskId: 'task', + )).future, + ), + throwsFormatException, + ); + }); + + test( + 'on-demand provider paginates, filters unsafe and duplicate links', + () async { + final requests = []; + final client = MicrosoftTodoRestApiClient( + httpClient: MockClient((request) async { + requests.add(request.url); + return http.Response( + jsonEncode( + request.url.queryParameters.containsKey(r'$skiptoken') + ? { + 'value': [ + { + 'id': 'second', + 'applicationName': 'Outlook', + 'displayName': 'Mail', + 'webUrl': 'https://example.test/mail', + }, + { + 'id': 'duplicate', + 'applicationName': 'Planner', + 'displayName': 'Task', + 'webUrl': 'https://example.test/task', + }, + ], + } + : { + 'value': [ + { + 'id': 'first', + 'applicationName': 'Planner', + 'displayName': 'Task', + 'webUrl': 'https://example.test/task', + }, + { + 'id': 'invalid', + 'applicationName': 'Unknown', + 'webUrl': 'javascript:alert(1)', + }, + ], + '@odata.nextLink': + 'https://graph.microsoft.com/v1.0/me/todo/lists/list/tasks/task/linkedResources?\$skiptoken=next', + }, + ), + 200, + ); + }), + baseUri: Uri.parse('https://graph.microsoft.com/v1.0'), + authorizationHeaderProvider: () async => 'Bearer token', + ); + final container = ProviderContainer( + overrides: [ + microsoftTodoApiClientForAccountProvider( + 'microsoft:a', + ).overrideWithValue(client), + ], + ); + addTearDown(container.dispose); + final links = await container.read( + microsoftTaskLinkedResourcesProvider(( + accountId: 'microsoft:a', + taskListId: 'list', + taskId: 'task', + )).future, + ); + expect(requests, hasLength(2)); + expect(links.map((link) => link.url), [ + 'https://example.test/task', + 'https://example.test/mail', + ]); + expect(links.last.label, 'Mail · Outlook'); + }, + ); +} diff --git a/test/microsoft_todo/oauth/microsoft_oauth_service_test.dart b/test/microsoft_todo/oauth/microsoft_oauth_service_test.dart index 6d6cb0e2..ac17698b 100644 --- a/test/microsoft_todo/oauth/microsoft_oauth_service_test.dart +++ b/test/microsoft_todo/oauth/microsoft_oauth_service_test.dart @@ -12,6 +12,284 @@ import 'package:busymax/src/microsoft_todo/oauth/microsoft_oauth_service.dart'; import 'package:busymax/src/providers/busy_provider.dart'; void main() { + test( + 'short Graph optional scopes authorize their existing account', + () async { + final store = InMemorySecretStore(); + final service = MicrosoftOAuthService( + config: _config, + httpClient: MockClient( + (_) async => throw StateError('No HTTP expected'), + ), + tokenStore: store, + loopbackFlow: OAuthLoopbackFlow( + authorizationLauncher: (_) async => false, + ), + nowUtc: () => DateTime.utc(2026, 6, 6), + ); + await store.saveOAuthTokenSet( + 'microsoft:user', + BusyProvider.microsoft, + OAuthTokenSet( + accessToken: 'short-access', + refreshToken: 'refresh', + expiresAtUtc: DateTime.utc(2026, 6, 7), + tokenType: 'Bearer', + scopes: const { + 'User.Read', + 'Tasks.ReadWrite', + 'Calendars.ReadWrite', + 'Calendars.ReadWrite.Shared', + 'MailboxSettings.Read', + }, + ), + ); + expect( + await service.sharedCalendarAuthorizationHeaderForAccount( + 'microsoft:user', + ), + 'Bearer short-access', + ); + expect( + await service.categoryAuthorizationHeaderForAccount('microsoft:user'), + 'Bearer short-access', + ); + await service.authorizeSharedCalendarAccess('microsoft:user'); + await service.authorizeCategoryAccess('microsoft:user'); + expect( + (await store.readOAuthTokenSet( + 'microsoft:user', + BusyProvider.microsoft, + ))!.accessToken, + 'short-access', + ); + }, + ); + + test( + 'short optional grants are requested on refresh but loss is honored', + () async { + late http.Request captured; + final service = _service((request) async { + captured = request; + return http.Response( + jsonEncode({ + 'access_token': 'renewed', + 'expires_in': 3600, + 'scope': 'User.Read Tasks.ReadWrite Calendars.ReadWrite', + }), + 200, + ); + }); + final token = await service.refreshToken( + OAuthTokenSet( + accessToken: 'old', + refreshToken: 'keep-refresh', + expiresAtUtc: DateTime.utc(2026, 6, 6), + tokenType: 'Bearer', + scopes: const { + 'User.Read', + 'Tasks.ReadWrite', + 'Calendars.ReadWrite', + 'Calendars.ReadWrite.Shared', + 'MailboxSettings.Read', + }, + ), + ); + final requested = Uri.splitQueryString(captured.body)['scope']!; + expect(requested, contains(microsoftSharedCalendarScope)); + expect(requested, contains(microsoftCategoryScope)); + expect(token.refreshToken, 'keep-refresh'); + expect(token.scopes, isNot(contains('Calendars.ReadWrite.Shared'))); + expect(token.scopes, isNot(contains('MailboxSettings.Read'))); + }, + ); + for (final optional in [ + microsoftSharedCalendarScope, + microsoftCategoryScope, + ]) { + test( + 'desktop consent accepts short and mixed Graph grants for $optional', + () async { + final store = InMemorySecretStore(); + final flow = _ConsentFlow(); + final prior = optional == microsoftSharedCalendarScope + ? microsoftCategoryScope + : microsoftSharedCalendarScope; + final priorShort = prior.substring( + 'https://graph.microsoft.com/'.length, + ); + final optionalShort = optional.substring( + 'https://graph.microsoft.com/'.length, + ); + await store.saveOAuthTokenSet( + 'microsoft:user', + BusyProvider.microsoft, + OAuthTokenSet( + accessToken: 'old', + refreshToken: 'old-refresh', + expiresAtUtc: DateTime.utc(2026, 6, 7), + tokenType: 'Bearer', + scopes: { + 'User.Read', + 'Tasks.ReadWrite', + 'Calendars.ReadWrite', + priorShort, + }, + ), + ); + final service = MicrosoftOAuthService( + config: _config, + httpClient: MockClient( + (request) async => request.method == 'GET' + ? http.Response(jsonEncode({'id': 'user'}), 200) + : http.Response( + jsonEncode({ + 'access_token': 'consented', + 'expires_in': 3600, + 'scope': + 'User.Read https://graph.microsoft.com/Tasks.ReadWrite ' + 'Calendars.ReadWrite $optionalShort $priorShort', + }), + 200, + ), + ), + tokenStore: store, + loopbackFlow: flow, + nowUtc: () => DateTime.utc(2026, 6, 6), + ); + if (optional == microsoftSharedCalendarScope) { + await service.authorizeSharedCalendarAccess('microsoft:user'); + } else { + await service.authorizeCategoryAccess('microsoft:user'); + } + expect(flow.requestedScope, contains(optional)); + expect(flow.requestedScope, contains(prior)); + final saved = (await store.readOAuthTokenSet( + 'microsoft:user', + BusyProvider.microsoft, + ))!; + expect(saved.accessToken, 'consented'); + expect(saved.refreshToken, 'old-refresh'); + expect(saved.scopes, containsAll([optionalShort, priorShort])); + }, + ); + } + + test( + 'wrong-resource optional scopes do not authorize Graph access', + () async { + final store = InMemorySecretStore(); + await store.saveOAuthTokenSet( + 'microsoft:user', + BusyProvider.microsoft, + OAuthTokenSet( + accessToken: 'wrong', + refreshToken: 'refresh', + expiresAtUtc: DateTime.utc(2026, 6, 7), + tokenType: 'Bearer', + scopes: const { + 'https://example.test/Calendars.ReadWrite.Shared', + 'https://example.test/MailboxSettings.Read', + }, + ), + ); + final service = MicrosoftOAuthService( + config: _config, + httpClient: MockClient( + (_) async => throw StateError('No HTTP expected'), + ), + tokenStore: store, + loopbackFlow: _ConsentFlow(), + nowUtc: () => DateTime.utc(2026, 6, 6), + ); + await expectLater( + service.sharedCalendarAuthorizationHeaderForAccount('microsoft:user'), + throwsA(isA()), + ); + await expectLater( + service.categoryAuthorizationHeaderForAccount('microsoft:user'), + throwsA(isA()), + ); + }, + ); + + for (final optional in [ + microsoftSharedCalendarScope, + microsoftCategoryScope, + ]) { + for (final denial in [ + 'cancelled', + 'wrong account', + 'missing grant', + 'wrong resource', + ]) { + test('$optional $denial leaves the existing token intact', () async { + final store = InMemorySecretStore(); + final previous = OAuthTokenSet( + accessToken: 'working-access', + refreshToken: 'working-refresh', + expiresAtUtc: DateTime.utc(2026, 6, 7), + tokenType: 'Bearer', + scopes: const {'User.Read', 'Tasks.ReadWrite', 'Calendars.ReadWrite'}, + ); + await store.saveOAuthTokenSet( + 'microsoft:user', + BusyProvider.microsoft, + previous, + ); + final flow = _ConsentFlow( + error: denial == 'cancelled' + ? const OAuthException('Cancelled', 'Consent cancelled') + : null, + ); + final service = MicrosoftOAuthService( + config: _config, + httpClient: MockClient((request) async { + if (request.method == 'GET') { + return http.Response( + jsonEncode({ + 'id': denial == 'wrong account' ? 'other' : 'user', + }), + 200, + ); + } + return http.Response( + jsonEncode({ + 'access_token': 'candidate-access', + 'expires_in': 3600, + 'scope': denial == 'missing grant' + ? 'User.Read Tasks.ReadWrite Calendars.ReadWrite' + : denial == 'wrong resource' + ? 'User.Read Tasks.ReadWrite Calendars.ReadWrite ' + 'https://example.test/${optional.substring('https://graph.microsoft.com/'.length)}' + : 'User.Read Tasks.ReadWrite Calendars.ReadWrite ' + '${optional.substring('https://graph.microsoft.com/'.length)}', + }), + 200, + ); + }), + tokenStore: store, + loopbackFlow: flow, + nowUtc: () => DateTime.utc(2026, 6, 6), + ); + await expectLater( + optional == microsoftSharedCalendarScope + ? service.authorizeSharedCalendarAccess('microsoft:user') + : service.authorizeCategoryAccess('microsoft:user'), + throwsA(isA()), + ); + final saved = (await store.readOAuthTokenSet( + 'microsoft:user', + BusyProvider.microsoft, + ))!; + expect(saved.accessToken, 'working-access'); + expect(saved.refreshToken, 'working-refresh'); + expect(saved.scopes, previous.scopes); + }); + } + } + test('authorization URL uses Microsoft public desktop OAuth parameters', () { final uri = buildAuthorizationUri( authorizationEndpoint: Uri.https( @@ -93,6 +371,115 @@ void main() { expect(body.containsKey('client_secret'), isFalse); }); + test( + 'shared-calendar refresh retains optional scope and ordinary account token', + () async { + late http.Request captured; + final service = _service((request) async { + captured = request; + return http.Response( + jsonEncode({ + 'access_token': 'renewed', + 'refresh_token': 'refresh', + 'expires_in': 3600, + 'token_type': 'Bearer', + 'scope': '$microsoftTodoOAuthScopes $microsoftSharedCalendarScope', + }), + 200, + ); + }); + final token = await service.refreshToken( + OAuthTokenSet( + accessToken: 'access', + refreshToken: 'refresh', + expiresAtUtc: DateTime.utc(2026, 6, 6), + tokenType: 'Bearer', + scopes: const {microsoftSharedCalendarScope}, + ), + ); + expect( + Uri.splitQueryString(captured.body)['scope'], + contains(microsoftSharedCalendarScope), + ); + expect(token.scopes, contains(microsoftSharedCalendarScope)); + }, + ); + + test( + 'owner-context header refuses a token without optional consent', + () async { + final store = InMemorySecretStore(); + final service = MicrosoftOAuthService( + config: _config, + httpClient: MockClient( + (_) async => throw StateError('No request expected'), + ), + tokenStore: store, + loopbackFlow: OAuthLoopbackFlow( + authorizationLauncher: (_) async => true, + ), + nowUtc: () => DateTime.utc(2026, 6, 6), + ); + await store.saveOAuthTokenSet( + 'microsoft:user', + BusyProvider.microsoft, + OAuthTokenSet( + accessToken: 'ordinary', + refreshToken: 'refresh', + expiresAtUtc: DateTime.utc(2026, 6, 7), + tokenType: 'Bearer', + scopes: const {'https://graph.microsoft.com/Calendars.ReadWrite'}, + ), + ); + expect( + await service.authorizationHeaderForAccount('microsoft:user'), + 'Bearer ordinary', + ); + await expectLater( + service.sharedCalendarAuthorizationHeaderForAccount('microsoft:user'), + throwsA(isA()), + ); + await expectLater( + service.categoryAuthorizationHeaderForAccount('microsoft:user'), + throwsA(isA()), + ); + }, + ); + + test( + 'category consent remains account-scoped and survives refresh scope selection', + () async { + late http.Request captured; + final service = _service((request) async { + captured = request; + return http.Response( + jsonEncode({ + 'access_token': 'renewed', + 'refresh_token': 'refresh', + 'expires_in': 3600, + 'token_type': 'Bearer', + 'scope': '$microsoftTodoOAuthScopes $microsoftCategoryScope', + }), + 200, + ); + }); + final token = await service.refreshToken( + OAuthTokenSet( + accessToken: 'access', + refreshToken: 'refresh', + expiresAtUtc: DateTime.utc(2026, 6, 6), + tokenType: 'Bearer', + scopes: const {microsoftCategoryScope}, + ), + ); + expect( + Uri.splitQueryString(captured.body)['scope'], + contains(microsoftCategoryScope), + ); + expect(token.scopes, contains(microsoftCategoryScope)); + }, + ); + test('refresh failure preserves the token endpoint status', () async { final service = _service((request) async { return http.Response( @@ -240,3 +627,34 @@ http.Response _tokenResponse() { headers: {'Content-Type': 'application/json'}, ); } + +class _ConsentFlow extends OAuthLoopbackFlow { + _ConsentFlow({this.error}) : super(authorizationLauncher: (_) async => false); + + String? requestedScope; + final OAuthException? error; + + @override + Future start({ + required Uri authorizationEndpoint, + required String clientId, + required String scope, + String redirectHost = '127.0.0.1', + String signInCancelledMessage = 'Google sign-in was cancelled.', + String callbackNotReceivedMessage = googleSignInCallbackNotReceivedMessage, + String serverStartFailureMessage = + 'Could not start the local Google sign-in callback listener.', + String browserLaunchFailureMessage = + 'Could not open the browser for Google sign-in.', + Map extraAuthorizationParameters = const {}, + String? loginHint, + }) async { + requestedScope = scope; + if (error case final failure?) throw failure; + return const OAuthLoopbackResult( + callback: OAuthCallbackResult(code: 'code', scope: null), + redirectUri: 'http://localhost:4321/', + codeVerifier: 'verifier', + ); + } +} diff --git a/test/platform/android/android_adaptive_app_test.dart b/test/platform/android/android_adaptive_app_test.dart index bf906015..b4c30982 100644 --- a/test/platform/android/android_adaptive_app_test.dart +++ b/test/platform/android/android_adaptive_app_test.dart @@ -11,16 +11,25 @@ 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/calendar_providers/calendar_sync_dto.dart'; import 'package:busymax/src/config/build_config.dart'; import 'package:busymax/src/db/app_database.dart'; +import 'package:busymax/src/dav/storage/dav_object_repository.dart'; +import 'package:busymax/src/dav/mutation/dav_mutation_patch.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/schedule/presentation/attachment_upload_coordinator.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/google_calendar/google_calendar_models.dart'; +import 'package:busymax/src/microsoft_calendar/microsoft_calendar_api_client.dart'; +import 'package:busymax/src/microsoft_calendar/microsoft_calendar_models.dart'; +import 'package:busymax/src/microsoft_calendar/microsoft_shared_calendar_address.dart'; +import 'package:busymax/src/microsoft_todo/api/microsoft_todo_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'; @@ -38,6 +47,7 @@ import 'package:http/testing.dart'; import 'package:package_info_plus/package_info_plus.dart'; import '../../support/memory_settings_store.dart'; +import '../../test_localized_app.dart'; final _longScheduleDetailsNotes = List.generate( 60, @@ -548,6 +558,190 @@ void main() { expect(tester.takeException(), isNull); }); + testWidgets('read-only Android invitation shows details and meeting access', ( + tester, + ) async { + tester.view.physicalSize = const Size(412, 915); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + final harness = await _pumpApp( + tester, + AppSettings.defaults().copyWith( + androidScheduleViewMode: ScheduleViewMode.day, + ), + populated: true, + ); + addTearDown(harness.dispose); + await (harness.database.update(harness.database.calendarSources) + ..where((row) => row.id.equals('calendar'))) + .write(const CalendarSourcesCompanion(readOnly: Value(true))); + await (harness.database.update( + harness.database.calendarEvents, + )..where((row) => row.id.equals('invitation'))).write( + const CalendarEventsCompanion( + description: Value('Agenda and notes'), + startTimeZone: Value('Pacific/Honolulu'), + endTimeZone: Value('Asia/Tokyo'), + rawJson: Value('{"hangoutLink":"https://meet.google.com/room"}'), + webLink: Value('https://calendar.google.com/event'), + attachmentsJson: Value( + '[{"fileUrl":"https://drive.example/agenda",' + '"title":"Agenda attachment"}]', + ), + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.textContaining('more').first); + await tester.pumpAndSettle(); + await tester.tap(find.text('Invitation event')); + await tester.pumpAndSettle(); + expect(find.text('Agenda and notes'), findsOneWidget); + expect(find.text('Organizer: organizer@example.com'), findsOneWidget); + expect(find.text('Join meeting'), findsOneWidget); + expect(find.textContaining('Pacific/Honolulu'), findsNothing); + expect(find.textContaining('Asia/Tokyo'), findsNothing); + expect(find.text('Event link'), findsOneWidget); + expect(find.text('Agenda attachment'), findsOneWidget); + expect(find.text('Edit Event'), findsNothing); + const launcherChannel = MethodChannel('plugins.flutter.io/url_launcher'); + messenger.setMockMethodCallHandler(launcherChannel, (_) async => false); + addTearDown( + () => messenger.setMockMethodCallHandler(launcherChannel, null), + ); + await tester.tap(find.text('Join meeting')); + await tester.pumpAndSettle(); + expect(find.text('Could not open the meeting link.'), findsOneWidget); + }); + + testWidgets( + 'Android recurring event export chooses occurrence or authoritative series', + (tester) async { + tester.view.physicalSize = const Size(412, 915); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + final today = DateTime.now(); + final day = + '${today.year.toString().padLeft(4, '0')}-${today.month.toString().padLeft(2, '0')}-${today.day.toString().padLeft(2, '0')}'; + final next = today.add(const Duration(days: 1)); + final nextDay = + '${next.year.toString().padLeft(4, '0')}-${next.month.toString().padLeft(2, '0')}-${next.day.toString().padLeft(2, '0')}'; + final master = { + 'id': 'invitation', + 'iCalUID': 'series@example.test', + 'summary': 'Invitation event', + 'start': {'date': day}, + 'end': {'date': nextDay}, + 'recurrence': ['RRULE:FREQ=DAILY;COUNT=2'], + 'reminders': {'useDefault': false, 'overrides': []}, + }; + final client = GoogleCalendarApiClient( + httpClient: MockClient((request) async { + if (request.url.path.endsWith('/events/invitation')) { + return http.Response(jsonEncode(master), 200); + } + if (request.url.path.endsWith('/events')) { + expect(request.url.queryParameters['showDeleted'], 'true'); + return http.Response( + jsonEncode({ + 'items': [master], + }), + 200, + ); + } + return http.Response('unexpected export request', 404); + }), + baseUri: Uri.parse('https://www.googleapis.com'), + ); + final harness = await _pumpApp( + tester, + AppSettings.defaults().copyWith( + androidScheduleViewMode: ScheduleViewMode.day, + ), + populated: true, + googleSharingClient: client, + ); + addTearDown(harness.dispose); + await (harness.database.update( + harness.database.calendarEvents, + )..where((row) => row.id.equals('invitation'))).write( + const CalendarEventsCompanion( + recurrenceJson: Value('["RRULE:FREQ=DAILY;COUNT=2"]'), + ), + ); + await tester.pumpAndSettle(); + 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('Export')); + await tester.pumpAndSettle(); + expect(find.text('This event'), findsOneWidget); + expect(find.text('Entire series'), findsOneWidget); + String? exported; + messenger.setMockMethodCallHandler(platformChannel, (call) async { + if (call.method == 'createDocument') { + final args = (call.arguments as Map).cast(); + exported = utf8.decode((args['bytes'] as List).cast()); + return 'content://test/export.ics'; + } + return null; + }); + await tester.tap(find.text('Entire series')); + await tester.pumpAndSettle(); + expect(exported, contains('UID:series@example.test')); + expect(exported, contains('RRULE:FREQ=DAILY;COUNT=2')); + expect( + await harness.database.select(harness.database.pendingOps).get(), + isEmpty, + ); + }, + ); + + testWidgets('Android timed detail labels projected local time correctly', ( + tester, + ) async { + tester.view.physicalSize = const Size(412, 915); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + final harness = await _pumpApp( + tester, + AppSettings.defaults().copyWith( + androidScheduleViewMode: ScheduleViewMode.day, + ), + populated: true, + ); + addTearDown(harness.dispose); + final stored = await (harness.database.select( + harness.database.calendarEvents, + )..where((row) => row.id.equals('overnight'))).getSingle(); + await (harness.database.update( + harness.database.calendarEvents, + )..where((row) => row.id.equals('overnight'))).write( + CalendarEventsCompanion( + startDateTime: Value( + DateTime.parse(stored.startDateTime!).toUtc().toIso8601String(), + ), + endDateTime: Value( + DateTime.parse(stored.endDateTime!).toUtc().toIso8601String(), + ), + startTimeZone: const Value('Pacific/Honolulu'), + endTimeZone: const Value('Asia/Tokyo'), + ), + ); + await tester.pumpAndSettle(); + final eventLabel = find.textContaining('Overnight event').first; + await tester.ensureVisible(eventLabel); + await tester.pumpAndSettle(); + await tester.tap(eventLabel); + await tester.pumpAndSettle(); + expect(find.textContaining('UTC'), findsWidgets); + expect(find.textContaining('Pacific/Honolulu'), findsNothing); + expect(find.textContaining('Asia/Tokyo'), findsNothing); + }); + testWidgets('notification event with no writable source opens read-only', ( tester, ) async { @@ -717,13 +911,294 @@ void main() { 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); + final dialog = find.byType(AndroidGuestAvailabilityDialog); + expect( + find.descendant(of: dialog, matching: find.text('free@example.com')), + findsOneWidget, + ); + expect( + find.descendant(of: dialog, matching: find.text('failed@example.com')), + findsOneWidget, + ); + expect( + find.descendant(of: dialog, matching: find.text('missing@example.com')), + findsOneWidget, + ); expect(tester.takeException(), isNull); }, ); + testWidgets('C Android Google availability resolves event wall time', ( + tester, + ) async { + Map? requestBody; + final client = GoogleCalendarApiClient( + httpClient: MockClient((request) async { + requestBody = jsonDecode(request.body) as Map; + return http.Response( + jsonEncode({ + 'calendars': { + 'guest@example.com': {'busy': []}, + }, + }), + 200, + ); + }), + baseUri: Uri.parse('https://www.googleapis.com'), + authorizationHeaderProvider: () async => 'Bearer token', + ); + final harness = await _pumpApp( + tester, + AppSettings.defaults(), + calendarClient: client, + ); + addTearDown(harness.dispose); + final draft = EventEditorDraft.existing( + eventId: 'event', + accountId: 'google:editable', + sourceId: 'calendar', + providerCalendarId: 'calendar', + title: 'Meeting', + allDay: false, + start: DateTime(2026, 7, 15, 9), + end: DateTime(2026, 7, 15, 10), + startTimeZone: 'America/Los_Angeles', + endTimeZone: 'America/Los_Angeles', + attendees: const [EventAttendeeDraft(email: 'guest@example.com')], + ); + final navigator = tester.state( + find.byType(Navigator).first, + ); + navigator.push( + MaterialPageRoute( + builder: (context) => Scaffold( + body: TextButton( + onPressed: () => showAndroidGuestAvailabilityDialog( + context, + accountId: draft.accountId, + provider: BusyProvider.google, + draft: draft, + ), + child: const Text('Open availability'), + ), + ), + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('Open availability')); + await tester.pumpAndSettle(); + expect(requestBody?['timeMin'], '2026-07-15T16:00:00.000Z'); + expect(requestBody?['timeMax'], '2026-07-15T17:00:00.000Z'); + }); + + testWidgets('Google all-day availability uses selected calendar timezone', ( + tester, + ) async { + Map? requestBody; + late EventEditorDraft draft; + late String calendarTimeZone; + final client = GoogleCalendarApiClient( + httpClient: MockClient((request) async { + requestBody = jsonDecode(request.body) as Map; + return http.Response( + jsonEncode({ + 'calendars': { + 'guest@example.com': {'busy': []}, + }, + }), + 200, + ); + }), + baseUri: Uri.parse('https://www.googleapis.com'), + authorizationHeaderProvider: () async => 'Bearer token', + ); + final harness = await _pumpApp( + tester, + AppSettings.defaults(), + calendarClient: client, + seedDatabase: (database) async { + await database + .into(database.accounts) + .insert( + AccountsCompanion.insert( + id: 'google:editable', + provider: 'google', + authority: 'https://accounts.google.com', + providerAccountId: 'me@example.com', + credentialKind: 'oauth', + authState: const Value('signed_in'), + createdAtUtc: '2026-07-01T00:00:00Z', + updatedAtUtc: '2026-07-01T00:00:00Z', + ), + ); + final repository = CalendarRepository(database: database); + await repository.upsertSource( + accountId: 'google:editable', + source: const CalendarSourceDto( + provider: BusyProvider.google, + providerCalendarId: 'calendar', + summary: 'Tokyo', + timeZone: 'Asia/Tokyo', + accessRole: 'owner', + ), + ); + await repository.upsertEvent( + accountId: 'google:editable', + event: const CalendarEventDto( + provider: BusyProvider.google, + providerCalendarId: 'calendar', + providerEventId: 'all-day', + title: 'All day', + allDay: true, + startDate: '2026-07-15', + endDate: '2026-07-16', + attendeesJson: [ + {'email': 'guest@example.com'}, + ], + ), + ); + final detail = await repository.loadEventDetail( + (await database.select(database.calendarEvents).getSingle()).id, + ); + draft = EventEditorDraft.fromEventDetail(detail!); + calendarTimeZone = CalendarSourceEntity.fromRow( + await database.select(database.calendarSources).getSingle(), + ).timeZone!; + }, + ); + addTearDown(harness.dispose); + expect(draft.startTimeZone, isNull); + final navigator = tester.state( + find.byType(Navigator).first, + ); + navigator.push( + MaterialPageRoute( + builder: (context) => Scaffold( + body: TextButton( + onPressed: () => showAndroidGuestAvailabilityDialog( + context, + accountId: draft.accountId, + provider: BusyProvider.google, + draft: draft, + calendarTimeZone: calendarTimeZone, + ), + child: const Text('Open availability'), + ), + ), + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('Open availability')); + await tester.pumpAndSettle(); + expect(requestBody?['timeMin'], '2026-07-14T15:00:00.000Z'); + expect(requestBody?['timeMax'], '2026-07-15T15:00:00.000Z'); + }); + + testWidgets('Microsoft guest availability preserves mixed results', ( + tester, + ) async { + final client = MicrosoftCalendarApiClient( + httpClient: MockClient( + (_) async => http.Response( + jsonEncode({ + 'value': [ + {'scheduleId': 'free@example.com', 'scheduleItems': []}, + { + 'scheduleId': 'denied@example.com', + 'error': {'responseCode': '5003', 'message': 'No access'}, + }, + ], + }), + 200, + headers: {'Content-Type': 'application/json'}, + ), + ), + baseUri: Uri.parse('https://graph.microsoft.com/v1.0'), + responseTimeZone: 'UTC', + accountTenantId: '11111111-2222-4333-8444-555555555555', + authorizationHeaderProvider: () async => 'Bearer test-token', + ); + final harness = await _pumpApp( + tester, + AppSettings.defaults(), + calendarClient: client, + ); + addTearDown(harness.dispose); + await harness.database + .into(harness.database.accounts) + .insert( + AccountsCompanion.insert( + id: 'microsoft:editable', + provider: 'microsoft', + authority: 'https://login.microsoftonline.com/common', + providerAccountId: 'editable', + credentialKind: 'oauth', + authState: const Value('signed_in'), + tenantId: const Value('11111111-2222-4333-8444-555555555555'), + createdAtUtc: '2026-09-14T00:00:00.000Z', + updatedAtUtc: '2026-09-14T00:00:00.000Z', + ), + ); + await tester.pumpAndSettle(); + final now = DateTime.now(); + const source = CalendarSourceEntity( + id: 'calendar', + accountId: 'microsoft:editable', + provider: BusyProvider.microsoft, + 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: 'denied@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)); + final dialog = find.byType(AndroidGuestAvailabilityDialog); + expect( + find.descendant(of: dialog, matching: find.text('denied@example.com')), + findsOneWidget, + ); + expect( + find.descendant(of: dialog, matching: find.text('missing@example.com')), + findsOneWidget, + ); + expect(tester.takeException(), isNull); + }); + testWidgets('Android Settings exposes diagnostics and feedback routes', ( tester, ) async { @@ -839,51 +1314,297 @@ void main() { expect(tester.takeException(), isNull); }); - testWidgets('modified Android event requires discard confirmation', ( + testWidgets('assigned task deletion warns and cancellation changes nothing', ( 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, + const task = TaskEntity( + accountId: 'google:assigned', + taskListId: 'assigned-list', + id: 'assigned-task', + title: 'Assigned task', + localDirty: false, + pendingDelete: false, + pendingMove: false, + rawJson: '{}', + assignmentInfoJson: + '{"surfaceType":"DOCUMENT","linkToTask":"https://docs.google.com/document/d/example"}', + updatedLocalAtUtc: '2026-09-14T00:00:00.000Z', ); - + var mutationStarted = 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)), - ), + builder: (_) => AndroidTaskEditor( + accountId: task.accountId, + provider: BusyProvider.google, + task: task, + accountLabel: 'Assigned account', + listLabel: 'Assigned list', + onMutationStarted: (_) => mutationStarted = true, ), ), ); await tester.pumpAndSettle(); - await tester.enterText(find.byType(TextFormField).first, 'Changed title'); - await tester.tap(find.byIcon(Icons.close)); + expect(find.text('Open in provider'), findsOneWidget); + expect( + find.text('https://docs.google.com/document/d/example'), + findsOneWidget, + ); + final editorList = find.descendant( + of: find.byType(AndroidTaskEditor), + matching: find.byType(ListView), + ); + for ( + var step = 0; + step < 40 && find.text('Delete Task').evaluate().isEmpty; + step++ + ) { + await tester.drag(editorList, const Offset(0, -400)); + await tester.pump(); + } + expect(find.text('Delete Task'), findsOneWidget); + await tester.ensureVisible(find.text('Delete Task')); + await tester.tap(find.text('Delete Task')); await tester.pumpAndSettle(); - - expect(find.text('Discard changes?'), findsOneWidget); - expect(find.text('Changed title'), findsOneWidget); + expect(find.textContaining('original task'), findsWidgets); + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + expect(mutationStarted, isFalse); + expect(find.byType(AndroidTaskEditor), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + testWidgets( + 'Microsoft task linked resources load only after opening detail', + (tester) async { + var requests = 0; + final todoClient = MicrosoftTodoRestApiClient( + httpClient: MockClient((request) async { + requests++; + if (request.url.path.endsWith('/attachments')) { + return http.Response( + jsonEncode({ + 'value': [ + { + 'id': 'attachment-1', + 'name': 'Notes.txt', + 'size': 12, + '@odata.type': '#microsoft.graph.taskFileAttachment', + }, + ], + }), + 200, + ); + } + expect( + request.url.path, + '/v1.0/me/todo/lists/list-1/tasks/task-1/linkedResources', + ); + return http.Response( + jsonEncode({ + 'value': [ + { + 'id': 'resource-1', + 'applicationName': 'Planner', + 'displayName': 'Launch plan', + 'webUrl': 'https://example.test/plan', + }, + { + 'id': 'resource-2', + 'applicationName': 'Outlook', + 'displayName': 'Message', + 'webUrl': 'javascript:alert(1)', + }, + ], + }), + 200, + ); + }), + baseUri: Uri.parse('https://graph.microsoft.com/v1.0'), + authorizationHeaderProvider: () async => 'Bearer token', + ); + final harness = await _pumpApp( + tester, + AppSettings.defaults(), + todoClient: todoClient, + ); + addTearDown(harness.dispose); + const task = TaskEntity( + accountId: 'microsoft:account', + taskListId: 'list-1', + id: 'task-1', + title: '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: 'microsoft:account', + provider: BusyProvider.microsoft, + task: task, + ), + ), + ); + await tester.pumpAndSettle(); + expect(requests, 0); + await tester.tap(find.text('Linked resources')); + await tester.pumpAndSettle(); + expect(requests, 1); + expect(find.text('Launch plan · Planner'), findsOneWidget); + expect(find.text('Message · Outlook'), findsNothing); + await tester.tap(find.byKey(const Key('task-attachments-load'))); + await tester.pumpAndSettle(); + expect(requests, 2); + expect(find.text('Notes.txt'), findsOneWidget); + expect(find.text('Task'), findsWidgets); + expect(tester.takeException(), isNull); + }, + ); + + testWidgets('Android task detail keeps an uncertain upload blocked', ( + tester, + ) async { + var posts = 0; + final todoClient = MicrosoftTodoRestApiClient( + httpClient: MockClient((request) async { + if (request.method == 'GET') { + return http.Response(jsonEncode({'value': []}), 200); + } + posts++; + throw http.ClientException('direct response lost'); + }), + baseUri: Uri.parse('https://graph.microsoft.com/v1.0'), + authorizationHeaderProvider: () async => 'Bearer token', + ); + final harness = await _pumpApp( + tester, + AppSettings.defaults(), + todoClient: todoClient, + ); + addTearDown(harness.dispose); + final coordinator = harness.container.read( + attachmentUploadCoordinatorProvider, + ); + const accountId = 'microsoft:account'; + const taskListId = 'list-1'; + const taskId = 'task-1'; + final key = AttachmentUploadCoordinator.taskKey( + accountId, + taskListId, + taskId, + ); + await expectLater( + coordinator.uploadTask( + client: todoClient, + accountId: accountId, + taskListId: taskListId, + taskId: taskId, + name: 'notes.txt', + contentType: 'text/plain', + bytes: [1], + ), + throwsA(isA()), + ); + expect( + await coordinator.reconcileTask( + client: todoClient, + accountId: accountId, + taskListId: taskListId, + taskId: taskId, + ), + AttachmentUploadStatus.unresolved, + ); + const task = TaskEntity( + accountId: accountId, + taskListId: taskListId, + id: taskId, + title: '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: accountId, + provider: BusyProvider.microsoft, + task: task, + ), + ), + ); + await tester.pumpAndSettle(); + expect(coordinator.canSubmit(key), isFalse); + final retry = find.text('Retry'); + await tester.ensureVisible(retry); + await tester.tap(retry); + await tester.pumpAndSettle(); + expect(coordinator.canSubmit(key), isFalse); + expect(posts, 1); + await tester.tap(find.text('Cancel').last); + await tester.pumpAndSettle(); + expect(coordinator.canSubmit(key), isFalse); + 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); @@ -999,7 +1720,10 @@ void main() { await tester.tap(find.widgetWithText(TextButton, 'Cancel')); await tester.pumpAndSettle(); expect(find.byType(AndroidEventEditor), findsOneWidget); - expect(find.text('Edited $label'), findsOneWidget); + expect( + find.descendant(of: field, matching: find.text('Edited $label')), + findsOneWidget, + ); expect(tester.takeException(), isNull); }); } @@ -1216,6 +1940,55 @@ void main() { expect(tester.takeException(), isNull); }); + testWidgets('B synced occurrence hides recurrence-rule editor', ( + tester, + ) async { + final harness = await _pumpApp(tester, AppSettings.defaults()); + addTearDown(harness.dispose); + const source = CalendarSourceEntity( + id: 'calendar', + accountId: 'google:a', + provider: BusyProvider.google, + providerCalendarId: 'provider-a', + summary: 'Calendar', + selected: true, + hidden: false, + readOnly: false, + isDeleted: false, + ); + final start = DateTime(2026, 8, 3, 9); + 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: 'Occurrence', + allDay: false, + start: start, + end: start.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); + expect( + find.byKey(const ValueKey('android-event-recurrence')), + findsNothing, + ); + expect(scopeAction, findsOneWidget); + }); + testWidgets('task recovery retains full creation recurrence semantics', ( tester, ) async { @@ -1361,154 +2134,664 @@ void main() { .select(harness.database.pendingOps) .get(); expect( - operations.map((operation) => operation.operation), - containsAllInOrder(['patch_task', 'move_task']), + 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 sharing entry opens owner calendar permissions', ( + tester, + ) async { + var permissionReads = 0; + final sharingClient = GoogleCalendarApiClient( + httpClient: MockClient((request) async { + if (request.url.path.endsWith('/acl')) { + permissionReads++; + return http.Response(jsonEncode({'items': []}), 200); + } + return http.Response('unexpected request', 404); + }), + baseUri: Uri.parse('https://www.googleapis.com'), + ); + final harness = await _pumpApp( + tester, + AppSettings.defaults(), + seedDatabase: _seedCalendarAdministration, + googleSharingClient: sharingClient, + ); + addTearDown(harness.dispose); + tester + .state(find.byType(Navigator).first) + .push( + MaterialPageRoute(builder: (_) => const AndroidSettingsScreen()), + ); + await tester.pumpAndSettle(); + final entry = find.byKey( + const Key('android-manage-sharing-google:administration'), + ); + await _scrollUntilBuilt(tester, entry); + await tester.tap(entry); + await tester.pumpAndSettle(); + expect(find.text('Manage calendar sharing'), findsWidgets); + expect(find.byKey(const Key('calendar-sharing-recipient')), findsOneWidget); + expect(permissionReads, 1); + }); + + testWidgets( + 'Android opened Microsoft calendar removal is local and cancellable', + (tester) async { + final key = const MicrosoftSharedPrimaryCalendarAddress( + owner: 'owner@example.test', + graphCalendarId: 'owner-primary', + ).sourceCalendarId; + final harness = await _pumpApp( + tester, + AppSettings.defaults(), + seedDatabase: (database) async { + await database + .into(database.accounts) + .insert( + AccountsCompanion.insert( + id: 'microsoft:owner', + provider: 'microsoft', + authority: 'https://login.microsoftonline.com/common', + providerAccountId: 'recipient', + credentialKind: 'oauth', + authState: const Value('signed_in'), + calendarsEnabled: const Value(true), + createdAtUtc: '2026-09-14T00:00:00Z', + updatedAtUtc: '2026-09-14T00:00:00Z', + ), + ); + await CalendarRepository(database: database).upsertSource( + accountId: 'microsoft:owner', + source: CalendarSourceDto( + provider: BusyProvider.microsoft, + providerCalendarId: key, + summary: 'Owner calendar', + readOnly: false, + isRemovable: true, + ), + ); + }, + ); + addTearDown(harness.dispose); + tester + .state(find.byType(Navigator).first) + .push( + MaterialPageRoute(builder: (_) => const AndroidSettingsScreen()), + ); + await tester.pumpAndSettle(); + await _scrollUntilBuilt(tester, find.text('Owner calendar')); + await tester.tap(find.text('Owner calendar').first); + await tester.pumpAndSettle(); + expect(find.text('Rename'), findsNothing); + expect(find.text('Calendar color'), findsNothing); + await tester.tap(find.text('Remove from my calendars')); + await tester.pumpAndSettle(); + expect(find.textContaining('will not be deleted'), findsOneWidget); + await tester.tap(find.text('Cancel').last); + await tester.pumpAndSettle(); + expect( + (await harness.database + .select(harness.database.calendarSources) + .getSingle()) + .isDeleted, + isFalse, + ); + expect( + await harness.database.select(harness.database.pendingOps).get(), + isEmpty, + ); + await tester.tap(find.text('Owner calendar').first); + await tester.pumpAndSettle(); + await tester.tap(find.text('Remove from my calendars')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Remove').last); + await tester.pumpAndSettle(); + expect( + (await harness.database + .select(harness.database.calendarSources) + .getSingle()) + .isDeleted, + isTrue, + ); + expect( + await harness.database.select(harness.database.pendingOps).get(), + isEmpty, + ); + }, + ); + + testWidgets('Android Nextcloud edits one reminder and keeps sibling alarms', ( + tester, + ) async { + final tomorrow = DateTime.now().toUtc().add(const Duration(days: 1)); + final start = DateTime.utc(tomorrow.year, tomorrow.month, tomorrow.day, 16); + final end = start.add(const Duration(hours: 1)); + String icsDate(DateTime value) => + '${value.toUtc().toIso8601String().split('.').first.replaceAll('-', '').replaceAll(':', '')}Z'; + const accountId = 'nextcloud:event-reminders'; + const collectionId = 'reminders-collection'; + const href = '/remote.php/dav/calendars/user/work/'; + late CalendarSourceEntity seededSource; + late EventEditorDraft seededDraft; + late String seededEventId; + final raw = + 'BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\n' + 'UID:reminders@example.test\r\nDTSTART:${icsDate(start)}\r\n' + 'DTEND:${icsDate(end)}\r\nSUMMARY:Reminders\r\n' + 'BEGIN:VALARM\r\nACTION:DISPLAY\r\nTRIGGER:-PT15M\r\n' + 'DESCRIPTION:First\r\nX-KEEP:first\r\nEND:VALARM\r\n' + 'BEGIN:VALARM\r\nACTION:DISPLAY\r\nTRIGGER:-PT30M\r\n' + 'DESCRIPTION:Second\r\nX-KEEP:second\r\nEND:VALARM\r\n' + 'BEGIN:VALARM\r\nACTION:DISPLAY\r\n' + 'TRIGGER;RELATED=END:-PT0M\r\n' + 'DESCRIPTION:At end\r\nX-KEEP:end\r\nEND:VALARM\r\n' + 'BEGIN:VALARM\r\nACTION:AUDIO\r\nTRIGGER:-PT5M\r\n' + 'X-KEEP:audio\r\nEND:VALARM\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n'; + final harness = await _pumpApp( + tester, + AppSettings.defaults(), + notificationBackend: _PermissionOnlyNotifications(), + onCalendarSync: (_) async {}, + seedDatabase: (database) async { + await tester.runAsync(() async { + await database + .into(database.accounts) + .insert( + AccountsCompanion.insert( + id: accountId, + provider: 'nextcloud', + authority: 'https://cloud.example.test', + providerAccountId: 'user', + credentialKind: 'nextcloud_app_password', + authState: const Value('signed_in'), + createdAtUtc: start.toIso8601String(), + updatedAtUtc: start.toIso8601String(), + ), + ); + await database + .into(database.davCollections) + .insert( + DavCollectionsCompanion.insert( + id: collectionId, + accountId: accountId, + hrefKey: href, + requestUri: 'https://cloud.example.test$href', + displayName: 'Work', + supportedComponentMask: const Value(1), + currentUserPrivilegesJson: const Value( + '["{DAV:}read","{DAV:}write"]', + ), + readOnly: const Value(false), + eventProjectionEnabled: const Value(true), + createdAtUtc: start.toIso8601String(), + updatedAtUtc: start.toIso8601String(), + ), + ); + await database + .into(database.calendarSources) + .insert( + CalendarSourcesCompanion.insert( + id: 'dav-calendar-$collectionId', + accountId: accountId, + provider: 'nextcloud', + providerCalendarId: href, + davCollectionId: const Value(collectionId), + summary: 'Work', + createdAtLocal: 0, + updatedAtLocal: 0, + ), + ); + final prepared = DavPreparedObject.parse( + hrefKey: '${href}reminders.ics', + requestUri: Uri.parse( + 'https://cloud.example.test${href}reminders.ics', + ), + etag: '"v1"', + contentType: 'text/calendar', + rawIcsBody: raw, + ); + await DavObjectRepository(database: database).commit( + DavCollectionCommit( + accountId: accountId, + collectionId: collectionId, + provider: BusyProvider.nextcloud, + objects: [prepared], + deletedHrefKeys: const {}, + completeMembership: true, + membershipHrefKeys: {prepared.hrefKey}, + finalCursorKind: 'dav_sync_token', + finalCursorValue: 'sync-1', + baselineGeneration: 1, + completedAtUtc: DateTime.now().toUtc(), + projectionRangeStartUtc: start.subtract(const Duration(days: 1)), + projectionRangeEndUtc: end.add(const Duration(days: 1)), + ), + ); + final repository = CalendarRepository(database: database); + seededSource = (await repository.watchSourcesForAccounts([ + accountId, + ]).first).single; + seededEventId = + (await database.select(database.calendarEvents).getSingle()).id; + seededDraft = EventEditorDraft.fromEventDetail( + (await repository.loadEventDetail(seededEventId))!, + ); + }); + }, + ); + addTearDown(harness.dispose); + final repository = CalendarRepository(database: harness.database); + expect(seededSource.capabilities.canEditEvents, isTrue); + await tester.pumpWidget( + UncontrolledProviderScope( + container: harness.container, + child: localizedTestApp( + child: const Scaffold(body: Text('Editor host')), + ), + ), + ); + tester + .state(find.byType(Navigator).first) + .push( + MaterialPageRoute( + builder: (_) => + AndroidEventEditor(sources: [seededSource], draft: seededDraft), + ), + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 400)); + final second = find.byKey(const Key('android-event-dav-reminder-1')); + await _scrollUntilBuilt(tester, second); + expect( + find.byKey(const Key('android-event-dav-reminder-0')), + findsOneWidget, ); - expect( - secureStorage, - isNot(contains('busymax.android.task-draft.google:move.move-task')), + tester.widget>(second).onChanged!.call(45); + await tester.pump(); + await tester.tap(find.widgetWithText(TextButton, 'Save')); + await tester.runAsync( + () => Future.delayed(const Duration(milliseconds: 100)), ); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + expect(find.byType(AndroidEventEditor), findsNothing); + final operations = (await tester.runAsync( + () => harness.database.select(harness.database.pendingOps).get(), + ))!; + expect(operations, hasLength(1)); + expect(operations.single.operationType, 'dav.update'); + final queuedBody = + DavMutationPatch.fromJsonString( + operations.single.mutationPatchJson!, + ).applyTo( + operations.single.baselineRawIcs!, + nowUtc: DateTime.now().toUtc(), + ); + expect(queuedBody, contains('TRIGGER:-PT45M')); + expect(queuedBody, contains('X-KEEP:first')); + expect(queuedBody, contains('X-KEEP:second')); + expect(queuedBody, contains('TRIGGER;RELATED=END:-PT0M')); + expect(queuedBody, contains('X-KEEP:end')); + expect(queuedBody, contains('X-KEEP:audio')); + final updated = (await tester.runAsync( + () => repository.loadEventDetail(seededEventId), + ))!; + final editedRows = androidNextcloudEditableReminderRows(updated.reminders)!; + expect(editedRows.map((row) => row.minutes), [15, 45]); + expect(jsonEncode(updated.reminders), contains('X-KEEP')); expect(tester.takeException(), isNull); }); - testWidgets('cancelling a dirty task move preserves the draft', ( + testWidgets('Android event label selection queues the scoped ID', ( tester, ) async { - final harness = await _pumpApp(tester, AppSettings.defaults()); + final harness = await _pumpApp( + tester, + AppSettings.defaults(), + seedDatabase: _seedCalendarAdministration, + googleLabels: const [ + GoogleEventLabel( + id: 'label-1', + name: 'Project', + backgroundColor: '#336699', + ), + ], + ); addTearDown(harness.dispose); - await _seedMovableTask(harness.database); - await tester.pump(); - await _openMovableTaskEditor(tester, _movableTask()); - - await tester.enterText( - find.widgetWithText(TextField, 'Title'), - 'Unsaved move edit', + const source = CalendarSourceEntity( + id: 'administration-calendar', + accountId: 'google:administration', + provider: BusyProvider.google, + providerCalendarId: 'administration-calendar', + summary: 'Administration calendar', + selected: true, + hidden: false, + readOnly: false, + isDeleted: false, + accessRole: 'owner', ); - 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')); + final now = DateTime.now().add(const Duration(hours: 1)); + tester + .state(find.byType(Navigator).first) + .push( + MaterialPageRoute( + builder: (_) => AndroidEventEditor( + sources: const [source], + draft: EventEditorDraft.newEvent( + accountId: source.accountId, + sourceId: source.id, + providerCalendarId: source.providerCalendarId, + start: now, + end: now.add(const Duration(hours: 1)), + ).copyWith(title: 'Planning'), + ), + ), + ); 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); + final selector = find.byKey( + const Key('android-event-label-administration-calendar'), ); + await _scrollUntilBuilt(tester, selector); + expect(tester.takeException(), isNull); + tester + .widget>(selector) + .onChanged! + .call('label-1'); + await tester.pumpAndSettle(); expect(tester.takeException(), isNull); + await tester.tap(find.widgetWithText(TextButton, 'Save')); + await tester.pumpAndSettle(); + final ops = await harness.database + .select(harness.database.pendingOps) + .get(); + expect(ops, hasLength(1)); + expect(jsonDecode(ops.single.requestJson)['eventLabelId'], 'label-1'); + await tester.pump(const Duration(seconds: 6)); }); - testWidgets('failed task move retains editor state and recovery', ( + testWidgets('Android primary Google editor queues native focus time', ( tester, ) async { - final harness = await _pumpApp(tester, AppSettings.defaults()); - addTearDown(harness.dispose); - await _seedMovableTask(harness.database, includeSource: false); - await tester.pump(); - await _openMovableTaskEditor( + final harness = await _pumpApp( tester, - _movableTask(taskListId: 'missing-source'), + AppSettings.defaults(), + seedDatabase: _seedGoogleStatusCalendar, + googleLabels: const [], ); - - await tester.enterText( - find.widgetWithText(TextField, 'Title'), - 'Recover after move failure', + addTearDown(harness.dispose); + const source = CalendarSourceEntity( + id: 'status-calendar', + accountId: 'google:status', + provider: BusyProvider.google, + providerCalendarId: 'status-calendar', + summary: 'Primary', + selected: true, + hidden: false, + readOnly: false, + isDeleted: false, + primaryCalendar: true, + accessRole: 'owner', ); - 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')); + final now = DateTime.now().add(const Duration(hours: 1)); + tester + .state(find.byType(Navigator).first) + .push( + MaterialPageRoute( + builder: (_) => AndroidEventEditor( + sources: const [source], + draft: EventEditorDraft.newEvent( + accountId: source.accountId, + sourceId: source.id, + providerCalendarId: source.providerCalendarId, + start: now, + end: now.add(const Duration(hours: 1)), + ).copyWith(title: 'Focus'), + ), + ), + ); await tester.pumpAndSettle(); - await tester.tap(find.widgetWithText(TextButton, 'Discard')); + final type = find.byKey(const Key('android-google-event-type')); + await _scrollUntilBuilt(tester, type); + tester + .widget>(type) + .onChanged! + .call('focusTime'); 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); + final decline = find.byKey(const Key('android-google-auto-decline')); + await _scrollUntilBuilt(tester, decline); + tester + .widget>(decline) + .onChanged! + .call('declineOnlyNewConflictingInvitations'); + await tester.pumpAndSettle(); + await tester.tap(find.widgetWithText(TextButton, 'Save')); + await tester.pumpAndSettle(); + final request = + jsonDecode( + (await harness.database + .select(harness.database.pendingOps) + .getSingle()) + .requestJson, + ) + as Map; + expect(request['eventType'], 'focusTime'); + expect( + (request['googleStatusProperties'] as Map)['autoDeclineMode'], + 'declineOnlyNewConflictingInvitations', + ); + await tester.pump(const Duration(seconds: 6)); }); - testWidgets('calendar administration requests prompt pending sync', ( + testWidgets('Android Microsoft category choice queues all assignments', ( tester, ) async { - var syncRequests = 0; final harness = await _pumpApp( tester, AppSettings.defaults(), - seedDatabase: _seedCalendarAdministration, - onCalendarSync: (_) async => syncRequests += 1, + seedDatabase: _seedMicrosoftCategoryCalendar, + microsoftCategories: const [ + MicrosoftMasterCategory( + id: 'category-1', + displayName: 'Work', + color: 'preset7', + ), + ], ); addTearDown(harness.dispose); + const source = CalendarSourceEntity( + id: 'microsoft-category-calendar', + accountId: 'microsoft:categories', + provider: BusyProvider.microsoft, + providerCalendarId: 'calendar', + summary: 'Work calendar', + selected: true, + hidden: false, + readOnly: false, + isDeleted: false, + ); + final now = DateTime.now().add(const Duration(hours: 1)); tester .state(find.byType(Navigator).first) .push( - MaterialPageRoute(builder: (_) => const AndroidSettingsScreen()), + MaterialPageRoute( + builder: (_) => AndroidEventEditor( + sources: const [source], + draft: EventEditorDraft.newEvent( + accountId: source.accountId, + sourceId: source.id, + providerCalendarId: source.providerCalendarId, + start: now, + end: now.add(const Duration(hours: 1)), + ).copyWith(title: 'Planning', categories: ['Unknown']), + ), + ), ); 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); + final option = find.widgetWithText(FilterChip, 'Work'); + await _scrollUntilBuilt(tester, option); + await tester.tap(option); + await tester.pumpAndSettle(); + await tester.tap(find.widgetWithText(TextButton, 'Save')); + await tester.pumpAndSettle(); + final request = + jsonDecode( + (await harness.database + .select(harness.database.pendingOps) + .getSingle()) + .requestJson, + ) + as Map; + expect(request['categoriesJson'], ['Unknown', 'Work']); + await tester.pump(const Duration(seconds: 6)); }); testWidgets('Android feedback preserves failure and rotates edited retry', ( @@ -1647,6 +2930,11 @@ Future<_AndroidAppHarness> _pumpApp( Future Function(String accountId)? onCalendarSync, FeedbackSubmissionService? feedbackService, CloudCalendarClient? calendarClient, + GoogleCalendarApiClient? googleSharingClient, + List? googleLabels, + List? microsoftCategories, + MicrosoftTodoApiClient? todoClient, + AndroidNotificationBackend? notificationBackend, Future Function(ScheduleRange range)? scheduleProjectionCoverage, }) async { final database = AppDatabase.memoryForTests(); @@ -1655,6 +2943,7 @@ Future<_AndroidAppHarness> _pumpApp( final notifications = AndroidNotificationService( database: database, settings: () => settings, + backend: notificationBackend, ); final container = ProviderContainer( overrides: [ @@ -1692,6 +2981,22 @@ Future<_AndroidAppHarness> _pumpApp( calendarRemoteApiClientForAccountProvider.overrideWith( (ref, accountId) => calendarClient, ), + if (googleSharingClient != null) + googleCalendarApiClientForAccountProvider.overrideWith( + (ref, accountId) => googleSharingClient, + ), + if (googleLabels != null) + googleEventLabelsForCalendarProvider.overrideWith( + (ref, key) async => googleLabels, + ), + if (microsoftCategories != null) + microsoftMasterCategoriesProvider.overrideWith( + (ref, accountId) async => microsoftCategories, + ), + if (todoClient != null) + microsoftTodoApiClientForAccountProvider.overrideWith( + (ref, accountId) => todoClient, + ), initialAppSettingsProvider.overrideWithValue(settings), localSettingsStoreProvider.overrideWithValue(MemorySettingsStore()), allAccountsSyncRunnerProvider.overrideWithValue(() async {}), @@ -2026,6 +3331,73 @@ Future _seedCalendarAdministration(AppDatabase database) async { ); } +Future _seedGoogleStatusCalendar(AppDatabase database) async { + const timestamp = '2026-09-14T00:00:00.000Z'; + await database + .into(database.accounts) + .insert( + AccountsCompanion.insert( + id: 'google:status', + provider: 'google', + authority: 'https://accounts.google.com', + providerAccountId: 'status', + credentialKind: 'oauth', + authState: const Value('signed_in'), + displayName: const Value('Status account'), + email: const Value('owner@example.com'), + createdAtUtc: timestamp, + updatedAtUtc: timestamp, + ), + ); + await database + .into(database.calendarSources) + .insert( + CalendarSourcesCompanion.insert( + id: 'status-calendar', + accountId: 'google:status', + provider: 'google', + providerCalendarId: 'status-calendar', + summary: 'Primary', + primaryCalendar: const Value(true), + accessRole: const Value('owner'), + createdAtLocal: 1, + updatedAtLocal: 1, + ), + ); +} + +Future _seedMicrosoftCategoryCalendar(AppDatabase database) async { + const timestamp = '2026-09-14T00:00:00.000Z'; + await database + .into(database.accounts) + .insert( + AccountsCompanion.insert( + id: 'microsoft:categories', + provider: 'microsoft', + authority: 'https://login.microsoftonline.com/common', + providerAccountId: 'categories', + credentialKind: 'oauth', + authState: const Value('signed_in'), + createdAtUtc: timestamp, + updatedAtUtc: timestamp, + ), + ); + await database + .into(database.calendarSources) + .insert( + CalendarSourcesCompanion.insert( + id: 'microsoft-category-calendar', + accountId: 'microsoft:categories', + provider: 'microsoft', + providerCalendarId: 'calendar', + summary: 'Work calendar', + accessRole: const Value('writer'), + createdAtLocal: 1, + updatedAtLocal: 1, + ), + ); +} + final class _RecordingFeedbackService implements FeedbackSubmissionService { final List submissions = []; bool fail = true; @@ -2038,6 +3410,12 @@ final class _RecordingFeedbackService implements FeedbackSubmissionService { } } +final class _PermissionOnlyNotifications extends Fake + implements AndroidNotificationBackend { + @override + Future requestNotificationPermission() async => true; +} + final class _AndroidAppHarness { const _AndroidAppHarness(this.tester, this.container, this.database); diff --git a/test/platform/android/android_authorization_broker_test.dart b/test/platform/android/android_authorization_broker_test.dart index 1e0afde7..b6e85c8a 100644 --- a/test/platform/android/android_authorization_broker_test.dart +++ b/test/platform/android/android_authorization_broker_test.dart @@ -104,4 +104,38 @@ void main() { expect(removal.arguments, containsPair('accountId', 'google:account')); expect(removal.arguments, containsPair('revoke', false)); }); + + test( + 'optional Outlook category header requires the grant even with an opaque token', + () async { + var includeCategory = false; + messenger.setMockMethodCallHandler(channel, (call) async { + expect(call.method, 'authorizeMicrosoftSilent'); + expect( + (call.arguments as Map)['scopes'], + contains('MailboxSettings.Read'), + ); + return { + 'accessToken': 'opaque-token-value', + 'nativeAccountId': 'native', + 'scopes': [ + 'User.Read', + 'Tasks.ReadWrite', + 'Calendars.ReadWrite', + if (includeCategory) 'MailboxSettings.Read', + ], + }; + }); + final subject = broker(InMemorySecretStore()); + await expectLater( + subject.microsoftCategoryAuthorizationHeader('microsoft:account'), + throwsA(isA()), + ); + includeCategory = true; + expect( + await subject.microsoftCategoryAuthorizationHeader('microsoft:account'), + 'Bearer opaque-token-value', + ); + }, + ); } diff --git a/test/platform/android/android_event_attendee_edit_test.dart b/test/platform/android/android_event_attendee_edit_test.dart index 0f694e25..22a3da1e 100644 --- a/test/platform/android/android_event_attendee_edit_test.dart +++ b/test/platform/android/android_event_attendee_edit_test.dart @@ -1,5 +1,6 @@ import 'package:busymax/src/android/presentation/android_schedule_screen.dart'; import 'package:busymax/src/features/calendar/presentation/event_editor_draft.dart'; +import 'package:busymax/src/providers/busy_provider.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { @@ -48,4 +49,217 @@ void main() { expect(identical(edit.attendees.first, existing), isTrue); expect(edit.attendees.last.email, 'new@example.test'); }); + + test('role edits preserve guest identity and response metadata', () { + final guest = EventAttendeeDraft.fromJson(const { + 'email': 'guest@example.test', + 'displayName': 'Guest', + 'responseStatus': 'accepted', + 'comment': 'Joining remotely', + 'additionalGuests': 2, + 'providerExtension': 'retain locally', + }); + final changed = applyAndroidEventAttendeeRoles( + AndroidEventAttendeeEdit(attendees: [guest], changed: false), + const {'guest@example.test': true}, + ); + expect(changed.changed, isTrue); + expect(changed.attendees.single.optional, isTrue); + expect(changed.attendees.single.displayName, 'Guest'); + expect(changed.attendees.single.responseStatus, 'accepted'); + expect( + changed.attendees.single.rawJson['providerExtension'], + 'retain locally', + ); + expect( + changed.attendees.single.toGoogleJson()['comment'], + 'Joining remotely', + ); + expect(changed.attendees.single.toGoogleJson()['additionalGuests'], 2); + }); + + test('reminder selection separates defaults, none and at-start', () { + expect( + androidEventReminderSelection(BusyProvider.google, const { + 'useDefault': true, + }), + -1, + ); + expect( + androidEventReminderSelection(BusyProvider.google, const { + 'useDefault': false, + 'overrides': [], + }), + -2, + ); + expect( + androidEventReminderSelection(BusyProvider.google, const { + 'useDefault': false, + 'overrides': [ + {'method': 'popup', 'minutes': 0}, + ], + }), + 0, + ); + expect( + androidEventReminderSelection(BusyProvider.microsoft, const { + 'isReminderOn': true, + 'reminderMinutesBeforeStart': 0, + }), + 0, + ); + expect( + androidEventReminderSelection(BusyProvider.google, const { + 'useDefault': false, + 'overrides': [ + {'method': 'popup', 'minutes': 0}, + {'method': 'popup', 'minutes': 10}, + ], + }), + isNull, + ); + expect( + androidEventReminderMinutes(BusyProvider.google, const { + 'useDefault': false, + 'overrides': [ + {'method': 'popup', 'minutes': 0}, + {'method': 'popup', 'minutes': 10}, + ], + }), + [0, 10], + ); + }); + + test('Nextcloud reminder rows retain alarm identities and zero minutes', () { + final rows = androidNextcloudEditableReminderRows(const { + 'minutes': [0, 10], + 'alarms': [ + { + 'properties': [ + {'name': 'ACTION', 'value': 'DISPLAY'}, + {'name': 'TRIGGER', 'value': '-PT0M'}, + ], + }, + { + 'properties': [ + {'name': 'ACTION', 'value': 'AUDIO'}, + {'name': 'TRIGGER', 'value': '-PT5M'}, + ], + }, + { + 'properties': [ + {'name': 'ACTION', 'value': 'DISPLAY'}, + {'name': 'TRIGGER', 'value': '-PT10M'}, + ], + }, + ], + }); + expect(rows!.map((row) => row.minutes), [0, 10]); + expect(rows.map((row) => row.originalIndex), [0, 2]); + expect( + androidNextcloudHasUnsupportedAlarms(const { + 'alarms': [ + { + 'properties': [ + {'name': 'ACTION', 'value': 'AUDIO'}, + ], + }, + ], + }), + isTrue, + ); + expect( + androidNextcloudEditableReminderRows(const { + 'davEditableRows': [ + {'originalIndex': 0, 'minutes': 0}, + {'minutes': 30}, + ], + })!.map((row) => row.toJson()), + [ + {'originalIndex': 0, 'minutes': 0}, + {'minutes': 30}, + ], + ); + }); + + test('G end-relative DISPLAY alarm is not an editable start reminder', () { + final rows = androidNextcloudEditableReminderRows(const { + 'alarms': [ + { + 'properties': [ + {'name': 'ACTION', 'value': 'DISPLAY'}, + {'name': 'TRIGGER', 'value': '-PT15M'}, + ], + }, + { + 'properties': [ + {'name': 'ACTION', 'value': 'DISPLAY'}, + { + 'name': 'TRIGGER', + 'value': '-PT0M', + 'parameters': [ + { + 'name': 'RELATED', + 'values': ['END'], + }, + ], + }, + ], + }, + ], + }); + expect(rows!.map((row) => row.originalIndex), [0]); + expect( + androidNextcloudHasUnsupportedAlarms(const { + 'alarms': [ + { + 'properties': [ + {'name': 'ACTION', 'value': 'DISPLAY'}, + { + 'name': 'TRIGGER', + 'value': '-PT0M', + 'parameters': [ + { + 'name': 'RELATED', + 'values': ['END'], + }, + ], + }, + ], + }, + ], + }), + isTrue, + ); + }); + + test('Google popup edits preserve other and unsupported reminders', () { + const original = { + 'useDefault': false, + 'overrides': [ + {'method': 'popup', 'minutes': 0, 'providerTag': 'retain'}, + {'method': 'email', 'minutes': 30}, + {'method': 'popup', 'minutes': 60}, + ], + }; + final changed = + androidEditGooglePopupReminder(original, overrideIndex: 2, minutes: 10) + as Map; + expect(changed['overrides'], [ + {'method': 'popup', 'minutes': 0, 'providerTag': 'retain'}, + {'method': 'email', 'minutes': 30}, + {'method': 'popup', 'minutes': 10}, + ]); + final removed = + androidRemoveGooglePopupReminder(changed, overrideIndex: 0) as Map; + expect(removed['overrides'], [ + {'method': 'email', 'minutes': 30}, + {'method': 'popup', 'minutes': 10}, + ]); + final added = + androidEditGooglePopupReminder(removed, overrideIndex: null, minutes: 5) + as Map; + expect((added['overrides'] as List).length, 3); + expect((added['overrides'] as List)[0], {'method': 'email', 'minutes': 30}); + }); } diff --git a/test/platform/android/android_recurrence_dialog_test.dart b/test/platform/android/android_recurrence_dialog_test.dart new file mode 100644 index 00000000..32ffa9ef --- /dev/null +++ b/test/platform/android/android_recurrence_dialog_test.dart @@ -0,0 +1,142 @@ +import 'package:busymax/l10n/generated/app_localizations.dart'; +import 'package:busymax/src/android/presentation/android_recurrence_dialog.dart'; +import 'package:busymax/src/features/recurrence/domain/event_recurrence_codec.dart'; +import 'package:busymax/src/features/recurrence/domain/recurrence_rule.dart'; +import 'package:busymax/src/providers/busy_provider.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; + +void main() { + testWidgets( + 'Android recurrence dialog retains interval, weekdays and count', + (tester) async { + final base = DateTime(2026, 6, 8, 9); + final initial = EventRecurrenceCodec.decode(BusyProvider.google, const [ + 'RRULE:FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,WE;WKST=MO;COUNT=12', + ], baseDate: base); + RecurrenceRule? result; + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: const [ + AppLocalizations.delegate, + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + ], + supportedLocales: const [Locale('en')], + home: Builder( + builder: (context) => Scaffold( + body: TextButton( + onPressed: () async => + result = await showAndroidRecurrenceDialog( + context, + initial: initial, + baseDate: base, + allDay: false, + timeZone: 'America/Vancouver', + limits: EventRecurrenceCodec.limitsFor( + BusyProvider.google, + ), + providerLabel: 'Google', + ), + child: const Text('Edit recurrence'), + ), + ), + ), + ), + ); + await tester.tap(find.text('Edit recurrence')); + await tester.pumpAndSettle(); + expect(find.text('2'), findsOneWidget); + expect(find.text('12'), findsOneWidget); + await tester.tap(find.text('Save')); + await tester.pumpAndSettle(); + expect(result, initial); + }, + ); + + test('frequency seed is provider-safe and anchored to the start day', () { + final rule = androidRuleForFrequency( + RecurrenceFrequency.weekly, + DateTime(2026, 6, 10), + ); + expect(rule.byDay, ['WE']); + expect( + EventRecurrenceCodec.canEncode(BusyProvider.microsoft, rule), + isTrue, + ); + }); + + testWidgets('invalid interval and count cannot silently save stale rules', ( + tester, + ) async { + final base = DateTime(2026, 6, 8, 9); + RecurrenceRule? result; + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: const [ + AppLocalizations.delegate, + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + ], + supportedLocales: const [Locale('en')], + home: Builder( + builder: (context) => Scaffold( + body: TextButton( + onPressed: () async => result = await showAndroidRecurrenceDialog( + context, + initial: androidRuleForFrequency( + RecurrenceFrequency.weekly, + base, + ), + baseDate: base, + allDay: false, + timeZone: 'UTC', + limits: EventRecurrenceCodec.limitsFor(BusyProvider.google), + providerLabel: 'Google', + ), + child: const Text('Edit recurrence'), + ), + ), + ), + ), + ); + await tester.tap(find.text('Edit recurrence')); + await tester.pumpAndSettle(); + await tester.enterText( + find.byKey( + const ValueKey( + 'android-recurrence-interval-RecurrenceFrequency.weekly', + ), + ), + 'oops', + ); + await tester.pump(); + expect( + tester + .widget(find.widgetWithText(FilledButton, 'Save')) + .onPressed, + isNull, + ); + await tester.enterText( + find.byKey( + const ValueKey( + 'android-recurrence-interval-RecurrenceFrequency.weekly', + ), + ), + '2', + ); + await tester.pump(); + expect( + tester + .widget(find.widgetWithText(FilledButton, 'Save')) + .onPressed, + isNotNull, + ); + await tester.tap(find.text('Save')); + await tester.pumpAndSettle(); + expect(result?.interval, 2); + }); +} diff --git a/test/platform/native_event_attachments_test.dart b/test/platform/native_event_attachments_test.dart new file mode 100644 index 00000000..fb1680cc --- /dev/null +++ b/test/platform/native_event_attachments_test.dart @@ -0,0 +1,2151 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:busymax/l10n/generated/app_localizations.dart'; +import 'package:busymax/src/android/presentation/android_event_attachments_dialog.dart'; +import 'package:busymax/src/app/app_bootstrap.dart'; +import 'package:busymax/src/calendar_providers/calendar_sync_dto.dart'; +import 'package:busymax/src/calendar_providers/attachment_upload_session.dart'; +import 'package:busymax/src/dav/storage/dav_object_repository.dart'; +import 'package:busymax/src/db/app_database.dart'; +import 'package:busymax/src/features/calendar/data/calendar_repository.dart'; +import 'package:busymax/src/features/schedule/presentation/linux_event_attachments_dialog.dart'; +import 'package:busymax/src/features/schedule/presentation/attachment_upload_coordinator.dart'; +import 'package:busymax/src/google_calendar/google_calendar_api_client.dart'; +import 'package:busymax/src/microsoft_calendar/microsoft_calendar_api_client.dart'; +import 'package:busymax/src/microsoft_calendar/microsoft_calendar_errors.dart'; +import 'package:busymax/src/providers/busy_provider.dart'; +import 'package:busymax/src/schedule/schedule_item.dart'; +import 'package:busymax/src/schedule/event_attachment_link.dart'; +import 'package:busymax/src/ui/windows/windows_event_attachments_dialog.dart'; +import 'package:fluent_ui/fluent_ui.dart' as fluent; +import 'package:drift/drift.dart'; +import 'package:drift/native.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'; +// The file_selector package does not re-export its platform test seam. +// ignore: depend_on_referenced_packages +import 'package:file_selector_platform_interface/file_selector_platform_interface.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; + +import '../test_localized_app.dart'; + +const _event = CalendarScheduleItem( + id: 'local-event', + accountId: 'microsoft:a', + provider: BusyProvider.microsoft, + sourceId: 'local-calendar', + providerCalendarId: 'remote-calendar', + providerEventId: 'remote-event', + title: 'Planning', + allDay: false, +); + +class _AttachmentTestFileSelector extends FileSelectorPlatform { + @override + Future openFile({ + List? acceptedTypeGroups, + String? initialDirectory, + String? confirmButtonText, + }) async => XFile.fromData( + Uint8List.fromList(const [1, 2, 3]), + path: '/test/agenda.txt', + mimeType: 'text/plain', + ); +} + +void main() { + test( + 'R3 missing retry header backs off repeated direct 429s without creating uncertainty', + () async { + var now = DateTime.utc(2026, 10, 1); + var submissions = 0; + final coordinator = AttachmentUploadCoordinator(now: () => now); + addTearDown(coordinator.dispose); + final key = AttachmentUploadCoordinator.eventKey( + 'account', + 'calendar', + 'event', + ); + Future upload() => coordinator.upload( + key: key, + name: 'a.txt', + size: 1, + contentType: 'text/plain', + bytes: const [1], + list: () async => const [], + submit: (_) async { + submissions++; + if (submissions <= 2) { + throw const MicrosoftCalendarApiError( + statusCode: 429, + code: 'TooManyRequests', + message: 'Throttled', + ); + } + return 'confirmed'; + }, + ); + await expectLater(upload(), throwsA(isA())); + final first = coordinator.retryAfter(key)!; + expect(first, greaterThanOrEqualTo(const Duration(seconds: 2))); + expect(first, lessThan(const Duration(seconds: 3))); + await expectLater( + upload(), + throwsA(isA()), + ); + expect(submissions, 1); + now = now.add(first + const Duration(milliseconds: 1)); + await expectLater(upload(), throwsA(isA())); + final second = coordinator.retryAfter(key)!; + expect(second, greaterThanOrEqualTo(const Duration(seconds: 4))); + expect(second, lessThan(const Duration(seconds: 5))); + expect(coordinator.needsReconciliation(key), isFalse); + now = now.add(second + const Duration(milliseconds: 1)); + await upload(); + expect(submissions, 3); + expect(coordinator.canSubmit(key), isTrue); + }, + ); + + test( + 'R3 throttled chunk does not discard an existing upload session', + () async { + final coordinator = AttachmentUploadCoordinator(); + addTearDown(coordinator.dispose); + final key = AttachmentUploadCoordinator.eventKey( + 'account', + 'calendar', + 'event', + ); + await expectLater( + coordinator.upload( + key: key, + name: 'large.bin', + size: 1, + contentType: 'application/octet-stream', + bytes: const [1], + list: () async => const [], + submit: (onSession) async { + onSession( + MicrosoftAttachmentUploadSession( + url: Uri.parse('https://graph.microsoft.com/session'), + expiresAt: DateTime.utc(2099), + nextOffset: 0, + ), + ); + throw const MicrosoftCalendarApiError( + statusCode: 429, + code: 'TooManyRequests', + message: 'Throttled', + ); + }, + ), + throwsA(isA()), + ); + expect(coordinator.hasResumableSession(key), isTrue); + expect(coordinator.needsReconciliation(key), isTrue); + expect(coordinator.retryAfter(key), equals(null)); + }, + ); + test( + 'R3 fresh direct event 429 is a rejected attempt, not an unresolved upload', + () async { + var now = DateTime.utc(2026, 10, 1); + var posts = 0; + final heldRefresh = Completer(); + final refreshStarted = Completer(); + final client = MicrosoftCalendarApiClient( + httpClient: MockClient((request) async { + if (request.method == 'GET') { + if (posts == 2) { + refreshStarted.complete(); + return heldRefresh.future; + } + return http.Response(jsonEncode({'value': []}), 200); + } + posts++; + if (posts == 2) { + return http.Response( + jsonEncode({ + 'id': 'confirmed-a', + 'name': 'notes.txt', + 'size': 1, + '@odata.type': '#microsoft.graph.fileAttachment', + }), + 201, + ); + } + return http.Response( + jsonEncode({ + 'error': {'code': 'TooManyRequests', 'message': 'Throttled'}, + }), + 429, + headers: {'retry-after': '30'}, + ); + }), + baseUri: Uri.parse('https://graph.microsoft.com/v1.0'), + responseTimeZone: 'UTC', + ); + final coordinator = AttachmentUploadCoordinator(now: () => now); + addTearDown(coordinator.dispose); + final key = AttachmentUploadCoordinator.eventKey( + 'account', + 'calendar', + 'event', + ); + Future upload() => coordinator.uploadEvent( + client: client, + accountId: 'account', + calendarId: 'calendar', + eventId: 'event', + name: 'notes.txt', + contentType: 'text/plain', + bytes: [1], + ); + await expectLater( + upload(), + throwsA( + isA().having( + (e) => e.retryAfter, + 'retryAfter', + const Duration(seconds: 30), + ), + ), + ); + expect(posts, 1); + expect(coordinator.status(key), AttachmentUploadStatus.ready); + expect(coordinator.needsReconciliation(key), isFalse); + expect(coordinator.retryAfter(key), const Duration(seconds: 30)); + expect(coordinator.canSubmit(key), isFalse); + expect( + await client.listEventAttachments( + calendarId: 'calendar', + eventId: 'event', + ), + isEmpty, + ); + await expectLater( + upload(), + throwsA(isA()), + ); + expect(posts, 1); + now = now.add(const Duration(seconds: 31)); + await upload(); + await refreshStarted.future; + expect(posts, 2); + expect(coordinator.status(key), AttachmentUploadStatus.committed); + expect(coordinator.canSubmit(key), isTrue); + heldRefresh.complete( + http.Response(jsonEncode({'value': []}), 200), + ); + }, + ); + + for (final platform in ['Linux', 'Windows', 'Android']) { + testWidgets( + '$platform R3 direct upload throttling keeps Close usable and gates explicit retry', + (tester) async { + final previousSelector = FileSelectorPlatform.instance; + FileSelectorPlatform.instance = _AttachmentTestFileSelector(); + addTearDown(() => FileSelectorPlatform.instance = previousSelector); + const android = MethodChannel('io.busystack.busymax/android'); + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + messenger.setMockMethodCallHandler(android, (call) async { + if (call.method != 'openDocument') return null; + return { + 'uri': 'content://busymax.test/agenda', + 'name': 'agenda.txt', + 'mimeType': 'text/plain', + 'bytes': Uint8List.fromList(const [1, 2, 3]), + }; + }); + addTearDown(() => messenger.setMockMethodCallHandler(android, null)); + var now = DateTime.utc(2026, 10, 1); + var posts = 0; + final client = MicrosoftCalendarApiClient( + httpClient: MockClient((request) async { + if (request.method == 'GET') { + return http.Response(jsonEncode({'value': []}), 200); + } + posts++; + if (posts == 1) { + return http.Response( + jsonEncode({ + 'error': {'code': 'TooManyRequests', 'message': 'Throttled'}, + }), + 429, + headers: {'retry-after': '30'}, + ); + } + return http.Response( + jsonEncode({ + 'id': 'attachment-a', + 'name': 'agenda.txt', + 'size': 3, + '@odata.type': '#microsoft.graph.fileAttachment', + }), + 201, + ); + }), + baseUri: Uri.parse('https://graph.microsoft.com/v1.0'), + responseTimeZone: 'UTC', + ); + final coordinator = AttachmentUploadCoordinator(now: () => now); + final key = AttachmentUploadCoordinator.eventKey( + 'microsoft:a', + 'remote-calendar', + 'remote-event', + ); + await tester.pumpWidget( + ProviderScope( + overrides: [ + attachmentUploadCoordinatorProvider.overrideWith( + (ref) => coordinator, + ), + microsoftCalendarApiClientForAccountProvider( + 'microsoft:a', + ).overrideWithValue(client), + ], + child: platform == 'Windows' + ? fluent.FluentApp( + localizationsDelegates: const [AppLocalizations.delegate], + supportedLocales: AppLocalizations.supportedLocales, + home: Builder( + builder: (context) => fluent.Button( + onPressed: () => + showWindowsEventAttachmentsDialog(context, _event), + child: const fluent.Text('Open attachments'), + ), + ), + ) + : localizedTestApp( + child: Scaffold( + body: Builder( + builder: (context) => TextButton( + onPressed: () => platform == 'Android' + ? showAndroidEventAttachmentsDialog( + context, + _event, + ) + : showLinuxEventAttachmentsDialog( + context, + _event, + ), + child: const Text('Open attachments'), + ), + ), + ), + ), + ), + ); + await tester.tap(find.text('Open attachments')); + for (var i = 0; i < 4; i++) { + await tester.pump(); + } + await tester.tap(find.byKey(const Key('event-attachment-add'))); + for (var i = 0; i < 20 && posts == 0; i++) { + await tester.pump(const Duration(milliseconds: 1)); + } + for (var i = 0; i < 4; i++) { + await tester.pump(); + } + expect(posts, 1); + expect(coordinator.needsReconciliation(key), isFalse); + expect(coordinator.canSubmit(key), isFalse); + expect( + find.text( + 'Attachment upload is temporarily rate-limited. Try again when the wait ends.', + ), + findsOneWidget, + ); + final add = tester.widget( + find.byKey(const Key('event-attachment-add')), + ); + expect(switch (add) { + fluent.Button button => button.onPressed, + TextButton button => button.onPressed, + FilledButton button => button.onPressed, + _ => null, + }, equals(null)); + await tester.tap(find.text('Close')); + await tester.pump(); + await tester.tap(find.text('Open attachments')); + for (var i = 0; i < 4; i++) { + await tester.pump(); + } + expect(posts, 1); + now = now.add(const Duration(seconds: 31)); + await tester.pump(const Duration(seconds: 31)); + expect(coordinator.canSubmit(key), isTrue); + await tester.tap(find.byKey(const Key('event-attachment-add'))); + for (var i = 0; i < 20 && posts < 2; i++) { + await tester.pump(const Duration(milliseconds: 1)); + } + expect(posts, 2); + if (platform == 'Windows') { + await tester.pump(const Duration(milliseconds: 200)); + } + }, + ); + } + + test('confirmed direct upload returns before its list refresh', () async { + final heldRefresh = Completer>(); + final refreshStarted = Completer(); + var lists = 0; + var submissions = 0; + final coordinator = AttachmentUploadCoordinator(); + final key = AttachmentUploadCoordinator.eventKey( + 'account', + 'calendar', + 'event', + ); + var completed = false; + final upload = coordinator + .upload( + key: key, + name: 'agenda.txt', + size: 1, + contentType: 'text/plain', + bytes: const [1], + list: () { + lists++; + if (lists == 1) return Future.value(const []); + refreshStarted.complete(); + return heldRefresh.future; + }, + submit: (_) async { + submissions++; + return 'attachment-a'; + }, + ) + .then((_) => completed = true); + await refreshStarted.future; + for (var i = 0; i < 4; i++) { + await Future.value(); + } + try { + expect(coordinator.status(key), AttachmentUploadStatus.committed); + expect(completed, isTrue); + expect(submissions, 1); + expect(lists, 2); + } finally { + heldRefresh.complete(const []); + await upload; + } + }); + + test( + 'a delayed confirmed refresh is harmless after coordinator disposal', + () async { + final heldRefresh = Completer>(); + final refreshStarted = Completer(); + var lists = 0; + final coordinator = AttachmentUploadCoordinator(); + final key = AttachmentUploadCoordinator.eventKey( + 'account', + 'calendar', + 'event', + ); + await coordinator.upload( + key: key, + name: 'agenda.txt', + size: 1, + contentType: 'text/plain', + bytes: const [1], + list: () { + if (lists++ == 0) return Future.value(const []); + refreshStarted.complete(); + return heldRefresh.future; + }, + submit: (_) async => 'attachment-a', + ); + await refreshStarted.future; + coordinator.dispose(); + heldRefresh.complete(const []); + for (var i = 0; i < 4; i++) { + await Future.value(); + } + expect(lists, 2); + }, + ); + + for (final platform in ['Linux', 'Windows', 'Android']) { + for (final refreshFails in [false, true]) { + testWidgets( + '$platform confirmed upload releases Add and Close before ${refreshFails ? 'failed' : 'successful'} list refresh', + (tester) async { + final previousSelector = FileSelectorPlatform.instance; + FileSelectorPlatform.instance = _AttachmentTestFileSelector(); + addTearDown(() => FileSelectorPlatform.instance = previousSelector); + const android = MethodChannel('io.busystack.busymax/android'); + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + messenger.setMockMethodCallHandler(android, (call) async { + if (call.method != 'openDocument') return null; + return { + 'uri': 'content://busymax.test/agenda', + 'name': 'agenda.txt', + 'mimeType': 'text/plain', + 'bytes': Uint8List.fromList(const [1, 2, 3]), + }; + }); + addTearDown(() { + messenger.setMockMethodCallHandler(android, null); + }); + + final heldRefresh = Completer(); + var uploads = 0; + var listRequests = 0; + var held = false; + final client = MicrosoftCalendarApiClient( + httpClient: MockClient((request) async { + if (request.method == 'GET' && + request.url.path.endsWith('/attachments')) { + listRequests++; + if (uploads == 1 && !held) { + held = true; + return heldRefresh.future; + } + return http.Response(jsonEncode({'value': []}), 200); + } + if (request.method == 'POST' && + request.url.path.endsWith('/attachments')) { + uploads++; + return http.Response( + jsonEncode({ + 'id': 'attachment-a', + 'name': 'agenda.txt', + 'size': 3, + '@odata.type': '#microsoft.graph.fileAttachment', + }), + 201, + ); + } + return http.Response('{}', 404); + }), + baseUri: Uri.parse('https://graph.microsoft.com/v1.0'), + responseTimeZone: 'UTC', + ); + final coordinator = AttachmentUploadCoordinator(); + final key = AttachmentUploadCoordinator.eventKey( + 'microsoft:a', + 'remote-calendar', + 'remote-event', + ); + await tester.pumpWidget( + ProviderScope( + overrides: [ + attachmentUploadCoordinatorProvider.overrideWith( + (ref) => coordinator, + ), + microsoftCalendarApiClientForAccountProvider( + 'microsoft:a', + ).overrideWithValue(client), + ], + child: platform == 'Windows' + ? fluent.FluentApp( + localizationsDelegates: const [AppLocalizations.delegate], + supportedLocales: AppLocalizations.supportedLocales, + home: Builder( + builder: (context) => fluent.Button( + onPressed: () => showWindowsEventAttachmentsDialog( + context, + _event, + ), + child: const fluent.Text('Open attachments'), + ), + ), + ) + : localizedTestApp( + child: Scaffold( + body: Builder( + builder: (context) => TextButton( + onPressed: () => platform == 'Android' + ? showAndroidEventAttachmentsDialog( + context, + _event, + ) + : showLinuxEventAttachmentsDialog( + context, + _event, + ), + child: const Text('Open attachments'), + ), + ), + ), + ), + ), + ); + await tester.tap(find.text('Open attachments')); + for (var i = 0; i < 4; i++) { + await tester.pump(); + } + await tester.tap(find.byKey(const Key('event-attachment-add'))); + for (var i = 0; i < 30 && !held; i++) { + await tester.pump(const Duration(milliseconds: 1)); + } + expect(held, isTrue, reason: 'uploads=$uploads lists=$listRequests'); + expect(coordinator.status(key), AttachmentUploadStatus.committed); + for (var i = 0; i < 3; i++) { + await tester.pump(); + } + final add = tester.widget( + find.byKey(const Key('event-attachment-add')), + ); + final close = platform == 'Windows' + ? tester.widget( + find.widgetWithText(fluent.Button, 'Close'), + ) + : platform == 'Android' + ? tester.widget( + find.widgetWithText(TextButton, 'Close'), + ) + : tester.widget( + find.widgetWithText(ElevatedButton, 'Close'), + ); + expect(switch (add) { + fluent.Button button => button.onPressed, + TextButton button => button.onPressed, + FilledButton button => button.onPressed, + _ => null, + }, isNot(equals(null))); + expect(switch (close) { + fluent.Button button => button.onPressed, + TextButton button => button.onPressed, + ElevatedButton button => button.onPressed, + _ => null, + }, isNot(equals(null))); + expect(uploads, 1); + await tester.tap(find.text('Close')); + await tester.pump(); + await tester.tap(find.text('Open attachments')); + await tester.pump(); + expect(uploads, 1); + if (refreshFails) { + heldRefresh.completeError(http.ClientException('list failed')); + } else { + heldRefresh.complete(http.Response(jsonEncode({'value': []}), 200)); + } + for (var i = 0; i < 4; i++) { + await tester.pump(); + } + expect(coordinator.status(key), AttachmentUploadStatus.committed); + expect(coordinator.canSubmit(key), isTrue); + expect(uploads, 1); + expect(listRequests, greaterThanOrEqualTo(2)); + expect(tester.takeException(), equals(null)); + if (platform == 'Windows') { + await tester.pump(const Duration(milliseconds: 200)); + } + }, + ); + } + } + + test( + 'another matching attachment never proves a lost upload succeeded', + () async { + var committed = false; + var visible = false; + var posts = 0; + final client = MicrosoftCalendarApiClient( + httpClient: MockClient((request) async { + if (request.method == 'GET' && + request.url.path.endsWith('/attachments')) { + return http.Response( + jsonEncode({ + 'value': visible + ? [ + { + 'id': 'new-attachment', + 'name': 'notes.txt', + 'size': 4, + '@odata.type': '#microsoft.graph.fileAttachment', + }, + ] + : [], + }), + 200, + ); + } + if (request.method == 'POST' && + request.url.path.endsWith('/attachments')) { + posts++; + committed = true; + throw http.ClientException('response lost'); + } + return http.Response('{}', 404); + }), + baseUri: Uri.parse('https://graph.microsoft.com/v1.0'), + responseTimeZone: 'UTC', + ); + final coordinator = AttachmentUploadCoordinator(); + final key = AttachmentUploadCoordinator.eventKey( + 'account', + 'calendar', + 'event', + ); + Future upload() => coordinator.uploadEvent( + client: client, + accountId: 'account', + calendarId: 'calendar', + eventId: 'event', + name: 'notes.txt', + contentType: 'text/plain', + bytes: [1, 2, 3, 4], + ); + await expectLater( + upload(), + throwsA(isA()), + ); + expect(committed, isTrue); + expect(coordinator.status(key), AttachmentUploadStatus.unresolved); + await expectLater( + upload(), + throwsA(isA()), + ); + expect(posts, 1); + visible = true; + expect( + await coordinator.reconcileEvent( + client: client, + accountId: 'account', + calendarId: 'calendar', + eventId: 'event', + ), + AttachmentUploadStatus.unresolved, + ); + expect(coordinator.canSubmit(key), isFalse); + expect(coordinator.canResolveManually(key), isTrue); + coordinator.resolveUncertainManually(key, exists: false); + expect(coordinator.canSubmit(key), isTrue); + }, + ); + + test('lost final event upload-session response remains unresolved', () async { + var visible = false; + var sessions = 0; + var chunks = 0; + final bytes = List.filled(3 * 1024 * 1024, 65); + final client = MicrosoftCalendarApiClient( + httpClient: MockClient((request) async { + if (request.method == 'GET' && + request.url.path.endsWith('/attachments')) { + return http.Response( + jsonEncode({ + 'value': visible + ? [ + { + 'id': 'session-attachment', + 'name': 'large.bin', + 'size': bytes.length, + '@odata.type': '#microsoft.graph.fileAttachment', + }, + ] + : [], + }), + 200, + ); + } + if (request.method == 'POST' && + request.url.path.endsWith('/createUploadSession')) { + sessions++; + return http.Response( + jsonEncode({ + 'uploadUrl': 'https://outlook.office.com/upload/session', + 'expirationDateTime': '2099-01-01T00:00:00Z', + 'nextExpectedRanges': ['0-'], + }), + 201, + ); + } + if (request.method == 'PUT') { + chunks++; + if (chunks == 2) throw http.ClientException('final response lost'); + return http.Response( + jsonEncode({ + 'nextExpectedRanges': ['2097152-'], + }), + 200, + ); + } + return http.Response('{}', 404); + }), + baseUri: Uri.parse('https://graph.microsoft.com/v1.0'), + responseTimeZone: 'UTC', + ); + final coordinator = AttachmentUploadCoordinator(); + Future upload() => coordinator.uploadEvent( + client: client, + accountId: 'account', + calendarId: 'calendar', + eventId: 'event', + name: 'large.bin', + contentType: 'application/octet-stream', + bytes: bytes, + ); + await expectLater( + upload(), + throwsA(isA()), + ); + await expectLater( + upload(), + throwsA(isA()), + ); + expect(sessions, 1); + expect(chunks, 2); + visible = true; + await expectLater( + coordinator.reconcileEvent( + client: client, + accountId: 'account', + calendarId: 'calendar', + eventId: 'event', + ), + throwsA(isA()), + ); + expect( + coordinator.status( + AttachmentUploadCoordinator.eventKey('account', 'calendar', 'event'), + ), + AttachmentUploadStatus.unresolved, + ); + }); + + test('lost nonfinal chunk resumes the same event session', () async { + var sessions = 0; + var statusReads = 0; + final ranges = []; + final bytes = List.filled(4 * 1024 * 1024, 65); + final client = MicrosoftCalendarApiClient( + httpClient: MockClient((request) async { + if (request.method == 'GET' && + request.url.path.endsWith('/attachments')) { + return http.Response(jsonEncode({'value': []}), 200); + } + if (request.method == 'POST' && + request.url.path.endsWith('/createUploadSession')) { + sessions++; + return http.Response( + jsonEncode({ + 'uploadUrl': 'https://outlook.office.com/upload/session', + 'expirationDateTime': '2099-01-01T00:00:00Z', + 'nextExpectedRanges': ['0-'], + }), + 201, + ); + } + if (request.method == 'GET' && + request.url.path.endsWith('/upload/session')) { + statusReads++; + return http.Response( + jsonEncode({ + 'nextExpectedRanges': ['2097152-'], + }), + 200, + ); + } + if (request.method == 'PUT') { + final range = + request.headers['content-range'] ?? + request.headers['Content-Range']!; + ranges.add(range); + if (range.startsWith('bytes 0-')) { + throw http.ClientException('first chunk response lost'); + } + return http.Response( + '', + 201, + headers: { + 'Location': + 'https://outlook.office.com/events/event/attachments/confirmed-id', + }, + ); + } + return http.Response('{}', 404); + }), + baseUri: Uri.parse('https://graph.microsoft.com/v1.0'), + responseTimeZone: 'UTC', + ); + final coordinator = AttachmentUploadCoordinator(); + final key = AttachmentUploadCoordinator.eventKey( + 'account', + 'calendar', + 'event', + ); + await expectLater( + coordinator.uploadEvent( + client: client, + accountId: 'account', + calendarId: 'calendar', + eventId: 'event', + name: 'large.bin', + contentType: 'application/octet-stream', + bytes: bytes, + ), + throwsA(isA()), + ); + expect(coordinator.status(key), AttachmentUploadStatus.unresolved); + expect( + await coordinator.reconcileEvent( + client: client, + accountId: 'account', + calendarId: 'calendar', + eventId: 'event', + ), + AttachmentUploadStatus.committed, + ); + expect(sessions, 1); + expect(statusReads, greaterThanOrEqualTo(1)); + expect(ranges, [ + 'bytes 0-2097151/${bytes.length}', + 'bytes 2097152-4194303/${bytes.length}', + ]); + }); + + test('incomplete event session can be cancelled before safe retry', () async { + var sessions = 0; + var cancellations = 0; + var denyCancellation = true; + final bytes = List.filled(4 * 1024 * 1024, 65); + final client = MicrosoftCalendarApiClient( + httpClient: MockClient((request) async { + if (request.method == 'GET' && + request.url.path.endsWith('/attachments')) { + return http.Response(jsonEncode({'value': []}), 200); + } + if (request.method == 'POST') { + sessions++; + return http.Response( + jsonEncode({ + 'uploadUrl': 'https://outlook.office.com/upload/session', + 'expirationDateTime': '2099-01-01T00:00:00Z', + 'nextExpectedRanges': ['0-'], + }), + 201, + ); + } + if (request.method == 'PUT') { + throw http.ClientException('nonfinal response lost'); + } + if (request.method == 'DELETE') { + cancellations++; + expect(request.headers.containsKey('authorization'), isFalse); + return http.Response('', denyCancellation ? 503 : 204); + } + return http.Response('{}', 404); + }), + baseUri: Uri.parse('https://graph.microsoft.com/v1.0'), + responseTimeZone: 'UTC', + ); + final coordinator = AttachmentUploadCoordinator(); + final key = AttachmentUploadCoordinator.eventKey( + 'account', + 'calendar', + 'event', + ); + Future upload() => coordinator.uploadEvent( + client: client, + accountId: 'account', + calendarId: 'calendar', + eventId: 'event', + name: 'large.bin', + contentType: 'application/octet-stream', + bytes: bytes, + ); + await expectLater( + upload(), + throwsA(isA()), + ); + expect(coordinator.hasResumableSession(key), isTrue); + await expectLater( + coordinator.cancelEvent( + client: client, + accountId: 'account', + calendarId: 'calendar', + eventId: 'event', + ), + throwsA(isA()), + ); + expect(coordinator.canSubmit(key), isFalse); + denyCancellation = false; + await coordinator.cancelEvent( + client: client, + accountId: 'account', + calendarId: 'calendar', + eventId: 'event', + ); + expect(cancellations, 2); + expect(coordinator.canSubmit(key), isTrue); + await expectLater( + upload(), + throwsA(isA()), + ); + expect(sessions, 2); + }); + + test( + 'malformed or expired session status never silently unlocks retry', + () async { + var reads = 0; + var puts = 0; + var sessions = 0; + final bytes = List.filled(4 * 1024 * 1024, 65); + final client = MicrosoftCalendarApiClient( + httpClient: MockClient((request) async { + if (request.method == 'GET' && + request.url.path.endsWith('/attachments')) { + return http.Response(jsonEncode({'value': []}), 200); + } + if (request.method == 'POST') { + sessions++; + return http.Response( + jsonEncode({ + 'uploadUrl': 'https://outlook.office.com/upload/session', + 'expirationDateTime': '2099-01-01T00:00:00Z', + 'nextExpectedRanges': ['0-'], + }), + 201, + ); + } + if (request.method == 'PUT') { + puts++; + throw http.ClientException('chunk response lost'); + } + if (request.method == 'GET') { + reads++; + return reads == 1 + ? http.Response(jsonEncode({'nextExpectedRanges': 'bad'}), 200) + : http.Response( + jsonEncode({ + 'nextExpectedRanges': ['2097152-'], + 'expirationDateTime': '2000-01-01T00:00:00Z', + }), + 200, + ); + } + return http.Response('{}', 404); + }), + baseUri: Uri.parse('https://graph.microsoft.com/v1.0'), + responseTimeZone: 'UTC', + ); + final coordinator = AttachmentUploadCoordinator(); + final key = AttachmentUploadCoordinator.eventKey( + 'account', + 'calendar', + 'event', + ); + await expectLater( + coordinator.uploadEvent( + client: client, + accountId: 'account', + calendarId: 'calendar', + eventId: 'event', + name: 'large.bin', + contentType: 'application/octet-stream', + bytes: bytes, + ), + throwsA(isA()), + ); + for (var i = 0; i < 2; i++) { + await expectLater( + coordinator.reconcileEvent( + client: client, + accountId: 'account', + calendarId: 'calendar', + eventId: 'event', + ), + throwsA(isA()), + ); + expect(coordinator.status(key), AttachmentUploadStatus.unresolved); + expect(coordinator.canSubmit(key), isFalse); + } + expect(sessions, 1); + expect(puts, 1); + expect(reads, 2); + expect(coordinator.canResolveManually(key), isTrue); + }, + ); + + test( + 'confirmed event attachment denial restores Add without committing', + () async { + var posts = 0; + final client = MicrosoftCalendarApiClient( + httpClient: MockClient((request) async { + if (request.method == 'GET') { + return http.Response(jsonEncode({'value': []}), 200); + } + posts++; + return http.Response( + jsonEncode({ + 'error': {'code': 'AccessDenied'}, + }), + 403, + ); + }), + baseUri: Uri.parse('https://graph.microsoft.com/v1.0'), + responseTimeZone: 'UTC', + ); + final coordinator = AttachmentUploadCoordinator(); + final key = AttachmentUploadCoordinator.eventKey( + 'account', + 'calendar', + 'event', + ); + await expectLater( + coordinator.uploadEvent( + client: client, + accountId: 'account', + calendarId: 'calendar', + eventId: 'event', + name: 'notes.txt', + contentType: 'text/plain', + bytes: [1], + ), + throwsA(isA()), + ); + expect(posts, 1); + expect(coordinator.status(key), AttachmentUploadStatus.ready); + expect(coordinator.canSubmit(key), isTrue); + }, + ); + + test( + 'confirmed upload with failed refresh permits a distinct upload', + () async { + var posts = 0; + var gets = 0; + var visible = false; + final client = MicrosoftCalendarApiClient( + httpClient: MockClient((request) async { + if (request.method == 'GET') { + gets++; + if (!visible && gets.isEven) { + throw http.ClientException('attachment list unavailable'); + } + return http.Response( + jsonEncode({ + 'value': visible + ? [ + { + 'id': 'committed-id', + 'name': 'notes.txt', + 'size': 4, + '@odata.type': '#microsoft.graph.fileAttachment', + }, + ] + : [], + }), + 200, + ); + } + posts++; + return http.Response( + jsonEncode({ + 'id': 'committed-id', + 'name': 'notes.txt', + 'size': 4, + '@odata.type': '#microsoft.graph.fileAttachment', + }), + 201, + ); + }), + baseUri: Uri.parse('https://graph.microsoft.com/v1.0'), + responseTimeZone: 'UTC', + ); + final coordinator = AttachmentUploadCoordinator(); + final key = AttachmentUploadCoordinator.eventKey( + 'account', + 'calendar', + 'event', + ); + Future upload() => coordinator.uploadEvent( + client: client, + accountId: 'account', + calendarId: 'calendar', + eventId: 'event', + name: 'notes.txt', + contentType: 'text/plain', + bytes: [1, 2, 3, 4], + ); + await upload(); + expect(coordinator.status(key), AttachmentUploadStatus.committed); + expect(coordinator.needsReconciliation(key), isTrue); + expect(coordinator.canSubmit(key), isTrue); + await upload(); + expect(posts, 2); + visible = true; + expect( + await coordinator.reconcileEvent( + client: client, + accountId: 'account', + calendarId: 'calendar', + eventId: 'event', + ), + AttachmentUploadStatus.committed, + ); + expect(coordinator.needsReconciliation(key), isFalse); + expect(coordinator.canSubmit(key), isTrue); + }, + ); + + test( + 'late confirmed refresh cannot unlock or relabel a newer attempt', + () async { + final firstRefresh = Completer>(); + var submissions = 0; + final coordinator = AttachmentUploadCoordinator(); + final key = AttachmentUploadCoordinator.eventKey( + 'account', + 'calendar', + 'event', + ); + final uploadA = coordinator.upload( + key: key, + name: 'a.txt', + size: 1, + contentType: 'text/plain', + bytes: const [1], + list: () => + submissions == 0 ? Future.value(const []) : firstRefresh.future, + submit: (_) async { + submissions++; + return 'attachment-a'; + }, + ); + for (var i = 0; i < 10 && coordinator.confirmedId(key) == null; i++) { + await Future.delayed(Duration.zero); + } + expect(coordinator.confirmedId(key), 'attachment-a'); + expect(coordinator.canSubmit(key), isTrue); + await expectLater( + coordinator.upload( + key: key, + name: 'b.txt', + size: 1, + contentType: 'text/plain', + bytes: const [2], + list: () async => const [], + submit: (_) async { + submissions++; + throw http.ClientException('response lost'); + }, + ), + throwsA(isA()), + ); + expect(coordinator.status(key), AttachmentUploadStatus.unresolved); + expect(coordinator.confirmedId(key), equals(null)); + coordinator.attachmentRemoved(key, 'attachment-a'); + expect(coordinator.status(key), AttachmentUploadStatus.unresolved); + firstRefresh.complete(const []); + await uploadA; + for (var i = 0; i < 4; i++) { + await Future.value(); + } + expect(coordinator.status(key), AttachmentUploadStatus.unresolved); + expect(coordinator.canSubmit(key), isFalse); + expect(submissions, 2); + }, + ); + + testWidgets('Linux Add remains enabled after confirmed refresh failure', ( + tester, + ) async { + var posts = 0; + final client = MicrosoftCalendarApiClient( + httpClient: MockClient((request) async { + if (request.method == 'GET') { + if (posts > 0) throw http.ClientException('list unavailable'); + return http.Response(jsonEncode({'value': []}), 200); + } + posts++; + return http.Response( + jsonEncode({ + 'id': 'attachment-a', + 'name': 'a.txt', + 'size': 1, + '@odata.type': '#microsoft.graph.fileAttachment', + }), + 201, + ); + }), + baseUri: Uri.parse('https://graph.microsoft.com/v1.0'), + responseTimeZone: 'UTC', + ); + final coordinator = AttachmentUploadCoordinator(); + await coordinator.uploadEvent( + client: client, + accountId: 'microsoft:a', + calendarId: 'remote-calendar', + eventId: 'remote-event', + name: 'a.txt', + contentType: 'text/plain', + bytes: const [1], + ); + await tester.pumpWidget( + ProviderScope( + overrides: [ + attachmentUploadCoordinatorProvider.overrideWith( + (ref) => coordinator, + ), + microsoftCalendarApiClientForAccountProvider( + 'microsoft:a', + ).overrideWithValue(client), + ], + child: localizedTestApp( + child: Scaffold( + body: Builder( + builder: (context) => TextButton( + onPressed: () => + showLinuxEventAttachmentsDialog(context, _event), + child: const Text('Open attachments'), + ), + ), + ), + ), + ), + ); + await tester.tap(find.text('Open attachments')); + await tester.pumpAndSettle(); + final add = tester.widget( + find.byKey(const Key('event-attachment-add')), + ); + expect(add.onPressed, isNot(equals(null))); + await tester.tap(find.text('Close')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Open attachments')); + await tester.pumpAndSettle(); + expect( + tester + .widget(find.byKey(const Key('event-attachment-add'))) + .onPressed, + isNot(equals(null)), + ); + expect(posts, 1); + }); + + test( + 'successful empty refresh clears confirmed presentation state', + () async { + var posts = 0; + var failRefresh = true; + final client = MicrosoftCalendarApiClient( + httpClient: MockClient((request) async { + if (request.method == 'GET') { + if (posts > 0 && failRefresh) { + failRefresh = false; + throw http.ClientException('list unavailable'); + } + return http.Response(jsonEncode({'value': []}), 200); + } + posts++; + return http.Response( + jsonEncode({ + 'id': 'attachment-a', + 'name': 'a.txt', + 'size': 1, + '@odata.type': '#microsoft.graph.fileAttachment', + }), + 201, + ); + }), + baseUri: Uri.parse('https://graph.microsoft.com/v1.0'), + responseTimeZone: 'UTC', + ); + final coordinator = AttachmentUploadCoordinator(); + final key = AttachmentUploadCoordinator.eventKey( + 'account', + 'calendar', + 'event', + ); + await coordinator.uploadEvent( + client: client, + accountId: 'account', + calendarId: 'calendar', + eventId: 'event', + name: 'a.txt', + contentType: 'text/plain', + bytes: const [1], + ); + expect(coordinator.confirmedId(key), 'attachment-a'); + expect( + await coordinator.reconcileEvent( + client: client, + accountId: 'account', + calendarId: 'calendar', + eventId: 'event', + ), + AttachmentUploadStatus.committed, + ); + expect(coordinator.confirmedId(key), equals(null)); + expect(coordinator.canSubmit(key), isTrue); + expect(posts, 1); + }, + ); + + test( + 'malformed success response cannot authorize a duplicate upload', + () async { + var posts = 0; + var visible = false; + final client = MicrosoftCalendarApiClient( + httpClient: MockClient((request) async { + if (request.method == 'GET') { + return http.Response( + jsonEncode({ + 'value': visible + ? [ + { + 'id': 'committed-id', + 'name': 'notes.txt', + 'size': 4, + '@odata.type': '#microsoft.graph.fileAttachment', + }, + ] + : [], + }), + 200, + ); + } + posts++; + return http.Response('not-json', 201); + }), + baseUri: Uri.parse('https://graph.microsoft.com/v1.0'), + responseTimeZone: 'UTC', + ); + final coordinator = AttachmentUploadCoordinator(); + final key = AttachmentUploadCoordinator.eventKey( + 'account', + 'calendar', + 'event', + ); + Future upload() => coordinator.uploadEvent( + client: client, + accountId: 'account', + calendarId: 'calendar', + eventId: 'event', + name: 'notes.txt', + contentType: 'text/plain', + bytes: [1, 2, 3, 4], + ); + await expectLater( + upload(), + throwsA(isA()), + ); + expect(coordinator.status(key), AttachmentUploadStatus.unresolved); + await expectLater( + upload(), + throwsA(isA()), + ); + expect(posts, 1); + visible = true; + expect( + await coordinator.reconcileEvent( + client: client, + accountId: 'account', + calendarId: 'calendar', + eventId: 'event', + ), + AttachmentUploadStatus.unresolved, + ); + }, + ); + for (final platform in ['Linux', 'Windows', 'Android']) { + testWidgets( + '$platform exposes review before retrying an uncertain event upload', + (tester) async { + var posts = 0; + final client = MicrosoftCalendarApiClient( + httpClient: MockClient((request) async { + if (request.method == 'GET') { + return http.Response(jsonEncode({'value': []}), 200); + } + posts++; + throw http.ClientException('direct response lost'); + }), + baseUri: Uri.parse('https://graph.microsoft.com/v1.0'), + responseTimeZone: 'UTC', + ); + final coordinator = AttachmentUploadCoordinator(); + final key = AttachmentUploadCoordinator.eventKey( + 'microsoft:a', + 'remote-calendar', + 'remote-event', + ); + await expectLater( + coordinator.uploadEvent( + client: client, + accountId: 'microsoft:a', + calendarId: 'remote-calendar', + eventId: 'remote-event', + name: 'notes.txt', + contentType: 'text/plain', + bytes: [1], + ), + throwsA(isA()), + ); + expect( + await coordinator.reconcileEvent( + client: client, + accountId: 'microsoft:a', + calendarId: 'remote-calendar', + eventId: 'remote-event', + ), + AttachmentUploadStatus.unresolved, + ); + await tester.pumpWidget( + ProviderScope( + overrides: [ + attachmentUploadCoordinatorProvider.overrideWith( + (ref) => coordinator, + ), + microsoftCalendarApiClientForAccountProvider( + 'microsoft:a', + ).overrideWithValue(client), + ], + child: platform == 'Windows' + ? fluent.FluentApp( + localizationsDelegates: const [AppLocalizations.delegate], + supportedLocales: AppLocalizations.supportedLocales, + home: Builder( + builder: (context) => fluent.Button( + onPressed: () => + showWindowsEventAttachmentsDialog(context, _event), + child: const fluent.Text('Open attachments'), + ), + ), + ) + : localizedTestApp( + child: Scaffold( + body: Builder( + builder: (context) => TextButton( + onPressed: () => platform == 'Android' + ? showAndroidEventAttachmentsDialog( + context, + _event, + ) + : showLinuxEventAttachmentsDialog( + context, + _event, + ), + child: const Text('Open attachments'), + ), + ), + ), + ), + ), + ); + await tester.tap(find.text('Open attachments')); + await tester.pumpAndSettle(); + expect(find.text('Retry'), findsOneWidget); + expect(find.byKey(const Key('event-attachment-add')), findsOneWidget); + expect(coordinator.canSubmit(key), isFalse); + await tester.tap(find.text('Retry')); + await tester.pumpAndSettle(); + expect(coordinator.canSubmit(key), isFalse); + await tester.tap(find.text('Retry').last); + await tester.pumpAndSettle(); + expect(coordinator.canSubmit(key), isTrue); + expect(posts, 1); + }, + ); + testWidgets('$platform queues a Nextcloud URI reference from details', ( + tester, + ) async { + final database = AppDatabase(NativeDatabase.memory()); + addTearDown(database.close); + final repository = CalendarRepository(database: database); + const accountId = 'nextcloud:a'; + const collectionId = 'dav-collection'; + const href = '/remote.php/dav/calendars/a/work/'; + const body = + 'BEGIN:VCALENDAR\r\n' + 'VERSION:2.0\r\n' + 'BEGIN:VEVENT\r\n' + 'UID:attachment@example.test\r\n' + 'DTSTART:20260608T090000Z\r\n' + 'DTEND:20260608T100000Z\r\n' + 'SUMMARY:Planning\r\n' + 'ATTACH:https://files.example/old.pdf\r\n' + 'END:VEVENT\r\n' + 'END:VCALENDAR\r\n'; + await database + .into(database.accounts) + .insert( + AccountsCompanion.insert( + id: accountId, + provider: 'nextcloud', + authority: 'https://cloud.example.test', + providerAccountId: 'a', + credentialKind: 'nextcloud_app_password', + authState: const Value('signed_in'), + createdAtUtc: '2026-06-08T00:00:00.000Z', + updatedAtUtc: '2026-06-08T00:00:00.000Z', + ), + ); + await database + .into(database.davCollections) + .insert( + DavCollectionsCompanion.insert( + id: collectionId, + accountId: accountId, + hrefKey: href, + requestUri: 'https://cloud.example.test$href', + displayName: 'Work', + supportedComponentMask: const Value(1), + currentUserPrivilegesJson: const Value( + '["{DAV:}read","{DAV:}write"]', + ), + readOnly: const Value(false), + eventProjectionEnabled: const Value(true), + createdAtUtc: '2026-06-08T00:00:00.000Z', + updatedAtUtc: '2026-06-08T00:00:00.000Z', + ), + ); + await database + .into(database.calendarSources) + .insert( + CalendarSourcesCompanion.insert( + id: 'dav-calendar-dav-collection', + accountId: accountId, + provider: 'nextcloud', + providerCalendarId: href, + davCollectionId: const Value(collectionId), + summary: 'Work', + createdAtLocal: 0, + updatedAtLocal: 0, + ), + ); + final prepared = DavPreparedObject.parse( + hrefKey: '${href}attachment.ics', + requestUri: Uri.parse( + 'https://cloud.example.test${href}attachment.ics', + ), + etag: '"v1"', + contentType: 'text/calendar', + rawIcsBody: body, + ); + await DavObjectRepository(database: database).commit( + DavCollectionCommit( + accountId: accountId, + collectionId: collectionId, + provider: BusyProvider.nextcloud, + objects: [prepared], + deletedHrefKeys: const {}, + completeMembership: true, + membershipHrefKeys: {prepared.hrefKey}, + finalCursorKind: 'dav_sync_token', + finalCursorValue: 'sync-1', + baselineGeneration: 1, + completedAtUtc: DateTime.utc(2026, 6, 8), + projectionRangeStartUtc: DateTime.utc(2026, 6), + projectionRangeEndUtc: DateTime.utc(2026, 7), + ), + ); + final event = await database.select(database.calendarEvents).getSingle(); + final item = CalendarScheduleItem( + id: event.id, + accountId: accountId, + provider: BusyProvider.nextcloud, + sourceId: event.calendarSourceId, + providerCalendarId: event.providerCalendarId, + providerEventId: event.providerEventId, + title: event.title, + allDay: event.allDay, + attachmentLinks: const [ + EventAttachmentLink( + url: 'https://files.example/old.pdf', + name: 'old.pdf', + ), + ], + attachmentsLoaded: true, + ); + await tester.pumpWidget( + ProviderScope( + overrides: [calendarRepositoryProvider.overrideWithValue(repository)], + child: platform == 'Windows' + ? fluent.FluentApp( + localizationsDelegates: const [AppLocalizations.delegate], + supportedLocales: AppLocalizations.supportedLocales, + home: Builder( + builder: (context) => fluent.Button( + onPressed: () => + showWindowsEventAttachmentsDialog(context, item), + child: const fluent.Text('Open attachments'), + ), + ), + ) + : localizedTestApp( + child: Scaffold( + body: Builder( + builder: (context) => TextButton( + onPressed: () => platform == 'Android' + ? showAndroidEventAttachmentsDialog(context, item) + : showLinuxEventAttachmentsDialog(context, item), + child: const Text('Open attachments'), + ), + ), + ), + ), + ), + ); + await tester.tap(find.text('Open attachments')); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('event-attachment-add'))); + await tester.pumpAndSettle(); + await tester.enterText( + find.byType(EditableText).last, + 'https://files.example/new.pdf', + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('Save').last); + await tester.pumpAndSettle(); + expect(find.text('new.pdf'), findsOneWidget); + final operation = await database.select(database.pendingOps).getSingle(); + expect(operation.operationType, 'dav.update'); + expect( + (await database.select(database.calendarEvents).getSingle()) + .attachmentsJson, + contains('new.pdf'), + ); + await tester.tap( + find.byKey(const Key('event-attachment-remove-reference')).first, + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('Delete').last); + await tester.pumpAndSettle(); + expect(find.text('old.pdf'), findsNothing); + final queued = await database.select(database.pendingOps).get(); + expect(queued, hasLength(1)); + expect( + (await database.select(database.calendarEvents).getSingle()) + .attachmentsJson, + isNot(contains('old.pdf')), + ); + }); + + testWidgets('$platform changes a Google reference and cached event', ( + tester, + ) async { + final database = AppDatabase(NativeDatabase.memory()); + addTearDown(database.close); + final repository = CalendarRepository(database: database); + await database + .into(database.accounts) + .insert( + AccountsCompanion.insert( + id: 'google:a', + provider: 'google', + authority: 'https://accounts.google.com', + providerAccountId: 'a', + credentialKind: 'oauth', + authState: const Value('signed_in'), + grantedScopes: const Value(''), + createdAtUtc: '2026-06-08T00:00:00.000Z', + updatedAtUtc: '2026-06-08T00:00:00.000Z', + ), + ); + await repository.upsertSource( + accountId: 'google:a', + source: const CalendarSourceDto( + provider: BusyProvider.google, + providerCalendarId: 'google-calendar', + summary: 'Google Calendar', + ), + ); + final attachments = >[]; + var patchCount = 0; + final requests = []; + Map event() => { + 'id': 'google-event', + 'etag': '"v${patchCount + 1}"', + 'summary': 'Planning', + 'start': {'dateTime': '2026-06-08T09:00:00Z'}, + 'end': {'dateTime': '2026-06-08T10:00:00Z'}, + 'attachments': attachments, + }; + final client = GoogleCalendarApiClient( + httpClient: MockClient((request) async { + requests.add('${request.method} ${request.url}'); + if (request.method == 'PATCH') { + patchCount++; + expect(request.url.queryParameters['supportsAttachments'], 'true'); + attachments + ..clear() + ..addAll([ + for (final item + in jsonDecode(request.body)['attachments'] as List) + Map.from(item as Map), + ]); + } + return http.Response(jsonEncode(event()), 200); + }), + baseUri: Uri.parse('https://www.googleapis.com'), + ); + const item = CalendarScheduleItem( + id: 'google-local', + accountId: 'google:a', + provider: BusyProvider.google, + sourceId: 'google-calendar', + providerCalendarId: 'google-calendar', + providerEventId: 'google-event', + title: 'Planning', + allDay: false, + attachmentsLoaded: true, + ); + await tester.pumpWidget( + ProviderScope( + overrides: [ + calendarRepositoryProvider.overrideWithValue(repository), + googleCalendarApiClientForAccountProvider( + 'google:a', + ).overrideWithValue(client), + ], + child: platform == 'Windows' + ? fluent.FluentApp( + localizationsDelegates: const [AppLocalizations.delegate], + supportedLocales: AppLocalizations.supportedLocales, + home: Builder( + builder: (context) => fluent.Button( + onPressed: () => + showWindowsEventAttachmentsDialog(context, item), + child: const fluent.Text('Open attachments'), + ), + ), + ) + : localizedTestApp( + child: Scaffold( + body: Builder( + builder: (context) => TextButton( + onPressed: () => platform == 'Android' + ? showAndroidEventAttachmentsDialog(context, item) + : showLinuxEventAttachmentsDialog(context, item), + child: const Text('Open attachments'), + ), + ), + ), + ), + ), + ); + await tester.tap(find.text('Open attachments')); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('event-attachment-add'))); + await tester.pumpAndSettle(); + await tester.enterText( + find.byType(EditableText).last, + 'https://drive.google.com/file/d/Agenda.pdf', + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('Save').last); + await tester.pumpAndSettle(); + expect( + patchCount, + 1, + reason: + 'Requests: $requests. Visible text: ${tester.widgetList(find.byType(Text)).map((widget) => widget.data).whereType().join(' | ')}', + ); + expect(find.text('Agenda.pdf'), findsOneWidget); + var rows = await database.select(database.calendarEvents).get(); + expect(rows.single.attachmentsJson, contains('Agenda.pdf')); + + await tester.tap( + find.byKey(const Key('event-attachment-remove-reference')), + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('Delete').last); + await tester.pumpAndSettle(); + expect(patchCount, 2); + expect(attachments, isEmpty); + rows = await database.select(database.calendarEvents).get(); + expect(rows.single.attachmentsJson, '[]'); + }); + + testWidgets('$platform shows Google attachment references without Graph', ( + tester, + ) async { + var graphRequests = 0; + const googleEvent = CalendarScheduleItem( + id: 'google-local', + accountId: 'google:a', + provider: BusyProvider.google, + sourceId: 'google-calendar', + providerCalendarId: 'google-calendar', + providerEventId: 'google-event', + title: 'Planning', + allDay: false, + attachmentLinks: [ + EventAttachmentLink( + url: 'https://drive.google.com/file/d/agenda/view', + name: 'Agenda.pdf', + ), + ], + attachmentsLoaded: true, + capabilities: ScheduleItemCapabilities.readOnly, + ); + final graph = MicrosoftCalendarApiClient( + httpClient: MockClient((request) async { + graphRequests++; + return http.Response('{"value":[]}', 200); + }), + baseUri: Uri.parse('https://graph.microsoft.com/v1.0'), + responseTimeZone: 'UTC', + ); + await tester.pumpWidget( + ProviderScope( + overrides: [ + microsoftCalendarApiClientForAccountProvider( + 'google:a', + ).overrideWithValue(graph), + ], + child: platform == 'Windows' + ? fluent.FluentApp( + localizationsDelegates: const [AppLocalizations.delegate], + supportedLocales: AppLocalizations.supportedLocales, + home: Builder( + builder: (context) => fluent.Button( + onPressed: () => showWindowsEventAttachmentsDialog( + context, + googleEvent, + ), + child: const fluent.Text('Open attachments'), + ), + ), + ) + : localizedTestApp( + child: Scaffold( + body: Builder( + builder: (context) => TextButton( + onPressed: () => platform == 'Android' + ? showAndroidEventAttachmentsDialog( + context, + googleEvent, + ) + : showLinuxEventAttachmentsDialog( + context, + googleEvent, + ), + child: const Text('Open attachments'), + ), + ), + ), + ), + ), + ); + await tester.tap(find.text('Open attachments')); + await tester.pumpAndSettle(); + expect(graphRequests, 0); + expect(find.text('Agenda.pdf'), findsOneWidget); + }); + + testWidgets('$platform event attachment dialog loads only when opened', ( + tester, + ) async { + var requests = 0; + final client = MicrosoftCalendarApiClient( + httpClient: MockClient((request) async { + requests++; + expect( + request.url.path, + '/v1.0/me/calendars/remote-calendar/events/remote-event/attachments', + ); + return http.Response( + jsonEncode({ + 'value': [ + { + 'id': 'file', + 'name': 'Agenda.pdf', + 'size': 4, + '@odata.type': '#microsoft.graph.fileAttachment', + }, + ], + }), + 200, + ); + }), + baseUri: Uri.parse('https://graph.microsoft.com/v1.0'), + responseTimeZone: 'UTC', + ); + final launcher = Builder( + builder: (context) => platform == 'Windows' + ? fluent.Button( + onPressed: () => + showWindowsEventAttachmentsDialog(context, _event), + child: const fluent.Text('Open attachments'), + ) + : TextButton( + onPressed: () => platform == 'Android' + ? showAndroidEventAttachmentsDialog(context, _event) + : showLinuxEventAttachmentsDialog(context, _event), + child: const Text('Open attachments'), + ), + ); + await tester.pumpWidget( + ProviderScope( + overrides: [ + microsoftCalendarApiClientForAccountProvider( + 'microsoft:a', + ).overrideWithValue(client), + ], + child: platform == 'Windows' + ? fluent.FluentApp( + localizationsDelegates: const [AppLocalizations.delegate], + supportedLocales: AppLocalizations.supportedLocales, + home: launcher, + ) + : localizedTestApp(child: Scaffold(body: launcher)), + ), + ); + expect(requests, 0); + await tester.tap(find.text('Open attachments')); + await tester.pumpAndSettle(); + expect(requests, 1); + expect(find.text('Agenda.pdf'), findsOneWidget); + expect(find.text('4 B'), findsOneWidget); + }); + + testWidgets( + '$platform removes a Microsoft attachment only after confirmation', + (tester) async { + var deletes = 0; + var posts = 0; + var failRefresh = false; + final client = MicrosoftCalendarApiClient( + httpClient: MockClient((request) async { + if (request.method == 'DELETE') { + deletes++; + expect( + request.url.path, + '/v1.0/me/calendars/remote-calendar/events/remote-event/attachments/file', + ); + return http.Response('', 204); + } + if (request.method == 'POST') { + posts++; + failRefresh = true; + return http.Response( + jsonEncode({ + 'id': 'file', + 'name': 'Agenda.pdf', + 'size': 4, + '@odata.type': '#microsoft.graph.fileAttachment', + }), + 201, + ); + } + if (failRefresh) { + failRefresh = false; + throw http.ClientException('list refresh unavailable'); + } + return http.Response( + jsonEncode({ + 'value': deletes == 0 + ? [ + { + 'id': 'file', + 'name': 'Agenda.pdf', + 'size': 4, + '@odata.type': '#microsoft.graph.fileAttachment', + }, + ] + : [], + }), + 200, + ); + }), + baseUri: Uri.parse('https://graph.microsoft.com/v1.0'), + responseTimeZone: 'UTC', + ); + final coordinator = AttachmentUploadCoordinator(); + final uploadKey = AttachmentUploadCoordinator.eventKey( + 'microsoft:a', + 'remote-calendar', + 'remote-event', + ); + await coordinator.uploadEvent( + client: client, + accountId: 'microsoft:a', + calendarId: 'remote-calendar', + eventId: 'remote-event', + name: 'Agenda.pdf', + contentType: 'application/pdf', + bytes: const [1, 2, 3, 4], + ); + expect(coordinator.confirmedId(uploadKey), 'file'); + expect(coordinator.canSubmit(uploadKey), isTrue); + await tester.pumpWidget( + ProviderScope( + overrides: [ + attachmentUploadCoordinatorProvider.overrideWith( + (ref) => coordinator, + ), + microsoftCalendarApiClientForAccountProvider( + 'microsoft:a', + ).overrideWithValue(client), + ], + child: platform == 'Windows' + ? fluent.FluentApp( + localizationsDelegates: const [AppLocalizations.delegate], + supportedLocales: AppLocalizations.supportedLocales, + home: fluent.Button( + onPressed: () => showWindowsEventAttachmentsDialog( + tester.element(find.byType(fluent.Button).first), + _event, + ), + child: const fluent.Text('Open attachments'), + ), + ) + : localizedTestApp( + child: Scaffold( + body: Builder( + builder: (context) => TextButton( + onPressed: () => platform == 'Android' + ? showAndroidEventAttachmentsDialog( + context, + _event, + ) + : showLinuxEventAttachmentsDialog( + context, + _event, + ), + child: const Text('Open attachments'), + ), + ), + ), + ), + ), + ); + await tester.tap(find.text('Open attachments')); + await tester.pumpAndSettle(); + if (platform == 'Android') { + await tester.tap(find.byTooltip('Delete').last); + } else { + await tester.tap(find.text('Delete').last); + } + await tester.pumpAndSettle(); + expect(deletes, 0); + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + expect(deletes, 0); + if (platform == 'Android') { + await tester.tap(find.byTooltip('Delete').last); + } else { + await tester.tap(find.text('Delete').last); + } + await tester.pumpAndSettle(); + await tester.tap(find.text('Delete').last); + await tester.pumpAndSettle(); + expect(deletes, 1); + expect(posts, 1); + expect(coordinator.confirmedId(uploadKey), equals(null)); + expect(coordinator.canSubmit(uploadKey), isTrue); + }, + ); + } +} diff --git a/test/platform/windows/windows_cloud_calendar_sharing_dialog_test.dart b/test/platform/windows/windows_cloud_calendar_sharing_dialog_test.dart new file mode 100644 index 00000000..d3aff305 --- /dev/null +++ b/test/platform/windows/windows_cloud_calendar_sharing_dialog_test.dart @@ -0,0 +1,579 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:busymax/l10n/generated/app_localizations.dart'; +import 'package:busymax/src/features/calendar/data/calendar_repository.dart'; +import 'package:busymax/src/features/calendar/data/cloud_calendar_sharing_service.dart'; +import 'package:busymax/src/features/connectivity/network_connectivity_service.dart'; +import 'package:busymax/src/microsoft_calendar/microsoft_calendar_api_client.dart'; +import 'package:busymax/src/providers/busy_provider.dart'; +import 'package:busymax/src/ui/windows/windows_cloud_calendar_sharing_dialog.dart'; +import 'package:fluent_ui/fluent_ui.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'; + +void main() { + testWidgets( + 'Windows rejected throttle keeps Close usable and gates retry across reopening', + (tester) async { + const source = CalendarSourceEntity( + id: 'windows-throttle', + accountId: 'windows-throttle-account', + provider: BusyProvider.microsoft, + providerCalendarId: 'windows-throttle-calendar', + summary: 'My calendar', + selected: true, + hidden: false, + readOnly: false, + isDeleted: false, + primaryCalendar: true, + ); + var clock = DateTime.now(); + var posts = 0; + var committed = false; + final client = MicrosoftCalendarApiClient( + httpClient: MockClient((request) async { + if (request.method == 'GET') { + return http.Response( + jsonEncode({ + 'value': [ + { + 'id': 'existing-grant', + 'role': 'read', + 'allowedRoles': ['read', 'write'], + 'isRemovable': true, + 'emailAddress': {'address': 'existing@example.test'}, + }, + if (committed) + { + 'id': 'new-grant', + 'role': 'freeBusyRead', + 'allowedRoles': ['freeBusyRead', 'read'], + 'isRemovable': true, + 'emailAddress': {'address': 'friend@example.test'}, + }, + ], + }), + 200, + ); + } + posts++; + if (posts == 1) { + return http.Response( + jsonEncode({ + 'error': {'code': 'TooManyRequests', 'message': 'Throttled'}, + }), + 429, + headers: {'retry-after': '5'}, + ); + } + committed = true; + return http.Response( + jsonEncode({ + 'id': 'new-grant', + 'role': 'freeBusyRead', + 'allowedRoles': ['freeBusyRead', 'read'], + 'isRemovable': true, + 'emailAddress': {'address': 'friend@example.test'}, + }), + 201, + ); + }), + baseUri: Uri.parse('https://graph.microsoft.com/v1.0'), + responseTimeZone: 'UTC', + ); + await tester.pumpWidget( + ProviderScope( + child: FluentApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Builder( + builder: (context) => Button( + onPressed: () => showWindowsCloudCalendarSharingDialog( + context, + sources: const [source], + serviceFactory: (s) => CloudCalendarSharingService( + source: s, + microsoft: client, + now: () => clock, + ), + ), + child: const Text('Open sharing'), + ), + ), + ), + ), + ); + await tester.tap(find.text('Open sharing')); + await tester.pumpAndSettle(); + await tester.enterText( + find.byKey(const Key('windows-sharing-recipient')), + 'friend@example.test', + ); + await tester.ensureVisible(find.byKey(const Key('windows-sharing-add'))); + await tester.tap(find.byKey(const Key('windows-sharing-add'))); + for (var i = 0; i < 4; i++) { + await tester.pump(); + } + expect(posts, 1); + expect( + find.text( + 'Sharing is temporarily rate-limited. Try again when the wait ends.', + ), + findsOneWidget, + ); + expect( + find.byKey(const Key('windows-sharing-grant-existing-grant')), + findsOneWidget, + ); + expect( + tester + .widget